Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.

This commit is contained in:
Veronika Smirnova
2025-08-03 17:04:29 +03:00
parent a33adda9d7
commit 8407ca397c
34 changed files with 5382 additions and 1795 deletions

View File

@ -1,84 +1,82 @@
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { NextRequest } from 'next/server'
import jwt from 'jsonwebtoken'
import { typeDefs } from '@/graphql/typedefs'
import { resolvers } from '@/graphql/resolvers'
// Интерфейс для контекста
interface Context {
user?: {
id: string
phone: string
}
admin?: {
id: string
username: string
}
}
import { ApolloServer } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { NextRequest } from "next/server";
import jwt from "jsonwebtoken";
import { typeDefs } from "@/graphql/typedefs";
import { resolvers } from "@/graphql/resolvers";
import { Context } from "@/graphql/context";
// Создаем Apollo Server
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
})
});
// Создаем Next.js handler
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
context: async (req: NextRequest) => {
// Извлекаем токен из заголовка Authorization
const authHeader = req.headers.get('authorization')
const token = authHeader?.replace('Bearer ', '')
const authHeader = req.headers.get("authorization");
const token = authHeader?.replace("Bearer ", "");
console.log('GraphQL Context - Auth header:', authHeader)
console.log('GraphQL Context - Token:', token ? `${token.substring(0, 20)}...` : 'No token')
console.log("GraphQL Context - Auth header:", authHeader);
console.log(
"GraphQL Context - Token:",
token ? `${token.substring(0, 20)}...` : "No token"
);
if (!token) {
console.log('GraphQL Context - No token provided')
return { user: undefined, admin: undefined }
console.log("GraphQL Context - No token provided");
return { user: undefined, admin: undefined };
}
try {
// Верифицируем JWT токен
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId?: string
phone?: string
adminId?: string
username?: string
type?: string
}
userId?: string;
phone?: string;
adminId?: string;
username?: string;
type?: string;
};
// Проверяем тип токена
if (decoded.type === 'admin' && decoded.adminId && decoded.username) {
console.log('GraphQL Context - Decoded admin:', { id: decoded.adminId, username: decoded.username })
if (decoded.type === "admin" && decoded.adminId && decoded.username) {
console.log("GraphQL Context - Decoded admin:", {
id: decoded.adminId,
username: decoded.username,
});
return {
admin: {
id: decoded.adminId,
username: decoded.username
}
}
username: decoded.username,
},
};
} else if (decoded.userId && decoded.phone) {
console.log('GraphQL Context - Decoded user:', { id: decoded.userId, phone: decoded.phone })
console.log("GraphQL Context - Decoded user:", {
id: decoded.userId,
phone: decoded.phone,
});
return {
user: {
id: decoded.userId,
phone: decoded.phone
}
}
phone: decoded.phone,
},
};
}
return { user: undefined, admin: undefined }
return { user: undefined, admin: undefined };
} catch (error) {
console.error('GraphQL Context - Invalid token:', error)
return { user: undefined, admin: undefined }
console.error("GraphQL Context - Invalid token:", error);
return { user: undefined, admin: undefined };
}
}
})
},
});
export async function GET(request: NextRequest) {
return handler(request)
return handler(request);
}
export async function POST(request: NextRequest) {
return handler(request)
}
return handler(request);
}

View File

@ -48,6 +48,65 @@ function PendingSuppliesNotification() {
);
}
// Компонент для отображения логистических заявок (только для логистики)
function LogisticsOrdersNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const logisticsCount =
pendingData?.pendingSuppliesCount?.logisticsOrders || 0;
if (logisticsCount === 0) return null;
return (
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
{logisticsCount > 99 ? "99+" : logisticsCount}
</div>
);
}
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
function FulfillmentSuppliesNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const suppliesCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
if (suppliesCount === 0) return null;
return (
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
{suppliesCount > 99 ? "99+" : suppliesCount}
</div>
);
}
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
function WholesaleOrdersNotification() {
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const ordersCount =
pendingData?.pendingSuppliesCount?.incomingSupplierOrders || 0;
if (ordersCount === 0) return null;
return (
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
{ordersCount > 99 ? "99+" : ordersCount}
</div>
);
}
export function Sidebar() {
const { user, logout } = useAuth();
const router = useRouter();
@ -149,7 +208,7 @@ export function Sidebar() {
router.push("/supplies");
break;
case "WHOLESALE":
router.push("/supplies");
router.push("/supplier-orders");
break;
case "LOGIST":
router.push("/logistics-orders");
@ -202,7 +261,8 @@ export function Sidebar() {
const isSuppliesActive =
pathname.startsWith("/supplies") ||
pathname.startsWith("/fulfillment-supplies") ||
pathname.startsWith("/logistics");
pathname.startsWith("/logistics") ||
pathname.startsWith("/supplier-orders");
const isPartnersActive = pathname.startsWith("/partners");
return (
@ -475,8 +535,7 @@ export function Sidebar() {
>
<Truck className="h-4 w-4 flex-shrink-0" />
{!isCollapsed && <span className="ml-3">Мои поставки</span>}
{/* Уведомление о непринятых поставках */}
<PendingSuppliesNotification />
{/* Селлеры не получают уведомления о поставках - только отслеживают статус */}
</Button>
)}
@ -536,8 +595,8 @@ export function Sidebar() {
{!isCollapsed && (
<span className="ml-3">Входящие поставки</span>
)}
{/* Уведомление о непринятых поставках */}
<PendingSuppliesNotification />
{/* Уведомление только о поставках, не о заявках на партнерство */}
<FulfillmentSuppliesNotification />
</Button>
)}
@ -595,8 +654,8 @@ export function Sidebar() {
>
<Truck className="h-4 w-4 flex-shrink-0" />
{!isCollapsed && <span className="ml-3">Заявки</span>}
{/* Уведомление о непринятых поставках */}
<PendingSuppliesNotification />
{/* Уведомление только о входящих заказах поставок, не о заявках на партнерство */}
<WholesaleOrdersNotification />
</Button>
)}
@ -616,8 +675,8 @@ export function Sidebar() {
>
<Truck className="h-4 w-4 flex-shrink-0" />
{!isCollapsed && <span className="ml-3">Перевозки</span>}
{/* Уведомление о непринятых поставках */}
<PendingSuppliesNotification />
{/* Уведомление только о логистических заявках */}
<LogisticsOrdersNotification />
</Button>
)}

View File

@ -1,86 +1,104 @@
"use client"
"use client";
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'
import { Sidebar } from '@/components/dashboard/sidebar'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useState, useEffect, useMemo } from "react";
import { useQuery, useMutation } from "@apollo/client";
import { apolloClient } from "@/lib/apollo-client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmployeeInlineForm } from './employee-inline-form'
import { EmployeeCompactForm } from './employee-compact-form'
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
import { EmployeeInlineForm } from "./employee-inline-form";
import { EmployeeCompactForm } from "./employee-compact-form";
import { EmployeeEditInlineForm } from "./employee-edit-inline-form";
import { EmployeeSearch } from './employee-search'
import { EmployeeLegend } from './employee-legend'
import { EmployeeEmptyState } from './employee-empty-state'
import { EmployeeRow } from './employee-row'
import { EmployeeReports } from './employee-reports'
import { toast } from 'sonner'
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
import { EmployeeSearch } from "./employee-search";
import { EmployeeLegend } from "./employee-legend";
import { EmployeeEmptyState } from "./employee-empty-state";
import { EmployeeRow } from "./employee-row";
import { EmployeeReports } from "./employee-reports";
import { toast } from "sonner";
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from "@/graphql/queries";
import {
CREATE_EMPLOYEE,
UPDATE_EMPLOYEE,
DELETE_EMPLOYEE,
UPDATE_EMPLOYEE_SCHEDULE,
} from "@/graphql/mutations";
import { Users, FileText, Plus, Layout, LayoutGrid } from "lucide-react";
// Интерфейс сотрудника
interface Employee {
id: string
firstName: string
lastName: string
middleName?: string
position: string
phone: string
email?: string
avatar?: string
hireDate: string
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
salary?: number
address?: string
birthDate?: string
passportSeries?: string
passportNumber?: string
passportIssued?: string
passportDate?: string
emergencyContact?: string
emergencyPhone?: string
telegram?: string
whatsapp?: string
passportPhoto?: string
createdAt: string
updatedAt: string
id: string;
firstName: string;
lastName: string;
middleName?: string;
position: string;
phone: string;
email?: string;
avatar?: string;
hireDate: string;
status: "ACTIVE" | "VACATION" | "SICK" | "FIRED";
salary?: number;
address?: string;
birthDate?: string;
passportSeries?: string;
passportNumber?: string;
passportIssued?: string;
passportDate?: string;
emergencyContact?: string;
emergencyPhone?: string;
telegram?: string;
whatsapp?: string;
passportPhoto?: string;
createdAt: string;
updatedAt: string;
}
export function EmployeesDashboard() {
const [searchQuery, setSearchQuery] = useState('')
const [showAddForm, setShowAddForm] = useState(false)
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
const [showEditForm, setShowEditForm] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
const [activeTab, setActiveTab] = useState('combined')
const [searchQuery, setSearchQuery] = useState("");
const [showAddForm, setShowAddForm] = useState(false);
const [showCompactForm, setShowCompactForm] = useState(true); // По умолчанию компактная форма
const [showEditForm, setShowEditForm] = useState(false);
const [createLoading, setCreateLoading] = useState(false);
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(
null
);
const [employeeSchedules, setEmployeeSchedules] = useState<{
[key: string]: ScheduleRecord[];
}>({});
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth());
const [activeTab, setActiveTab] = useState("combined");
interface ScheduleRecord {
id: string
date: string
status: string
hoursWorked?: number
id: string;
date: string;
status: string;
hoursWorked?: number;
employee: {
id: string
}
id: string;
};
}
// GraphQL запросы и мутации
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE)
const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees])
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES);
const [createEmployee] = useMutation(CREATE_EMPLOYEE, {
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
onCompleted: () => {
refetch(); // Принудительно обновляем список
},
});
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE, {
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
});
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE, {
refetchQueries: [{ query: GET_MY_EMPLOYEES }],
});
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE);
const employees = useMemo(() => data?.myEmployees || [], [data?.myEmployees]);
// Загружаем данные табеля для всех сотрудников
useEffect(() => {
@ -93,202 +111,217 @@ export function EmployeesDashboard() {
variables: {
employeeId: employee.id,
year: currentYear,
month: currentMonth
}
})
return { employeeId: employee.id, scheduleData: data?.employeeSchedule || [] }
month: currentMonth,
},
});
return {
employeeId: employee.id,
scheduleData: data?.employeeSchedule || [],
};
} catch (error) {
console.error(`Error loading schedule for ${employee.id}:`, error)
return { employeeId: employee.id, scheduleData: [] }
console.error(`Error loading schedule for ${employee.id}:`, error);
return { employeeId: employee.id, scheduleData: [] };
}
})
});
const results = await Promise.all(schedulePromises)
const scheduleMap: {[key: string]: ScheduleRecord[]} = {}
results.forEach((result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
if (result && result.scheduleData) {
scheduleMap[result.employeeId] = result.scheduleData
const results = await Promise.all(schedulePromises);
const scheduleMap: { [key: string]: ScheduleRecord[] } = {};
results.forEach(
(result: { employeeId: string; scheduleData: ScheduleRecord[] }) => {
if (result && result.scheduleData) {
scheduleMap[result.employeeId] = result.scheduleData;
}
}
})
setEmployeeSchedules(scheduleMap)
);
setEmployeeSchedules(scheduleMap);
}
}
};
loadScheduleData()
}, [employees, currentYear, currentMonth])
loadScheduleData();
}, [employees, currentYear, currentMonth]);
const handleEditEmployee = (employee: Employee) => {
setEditingEmployee(employee)
setShowEditForm(true)
setShowAddForm(false) // Закрываем форму добавления если открыта
}
setEditingEmployee(employee);
setShowEditForm(true);
setShowAddForm(false); // Закрываем форму добавления если открыта
};
const handleEmployeeSaved = async (employeeData: Partial<Employee>) => {
try {
if (editingEmployee) {
// Обновление существующего сотрудника
const { data } = await updateEmployee({
variables: {
variables: {
id: editingEmployee.id,
input: employeeData
}
})
input: employeeData,
},
});
if (data?.updateEmployee?.success) {
toast.success('Сотрудник успешно обновлен')
refetch()
toast.success("Сотрудник успешно обновлен");
refetch();
}
} else {
// Добавление нового сотрудника
// Добавление нового сотрудника
const { data } = await createEmployee({
variables: { input: employeeData }
})
variables: { input: employeeData },
});
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен')
refetch()
toast.success("Сотрудник успешно добавлен");
refetch();
}
}
setShowEditForm(false)
setEditingEmployee(null)
setShowEditForm(false);
setEditingEmployee(null);
} catch (error) {
console.error('Error saving employee:', error)
toast.error('Ошибка при сохранении сотрудника')
console.error("Error saving employee:", error);
toast.error("Ошибка при сохранении сотрудника");
}
}
};
const handleCreateEmployee = async (employeeData: Partial<Employee>) => {
setCreateLoading(true)
setCreateLoading(true);
try {
const { data } = await createEmployee({
variables: { input: employeeData }
})
variables: { input: employeeData },
});
if (data?.createEmployee?.success) {
toast.success('Сотрудник успешно добавлен!')
setShowAddForm(false)
refetch()
toast.success("Сотрудник успешно добавлен!");
setShowAddForm(false);
refetch();
}
} catch (error) {
console.error('Error creating employee:', error)
toast.error('Ошибка при создании сотрудника')
console.error("Error creating employee:", error);
toast.error("Ошибка при создании сотрудника");
} finally {
setCreateLoading(false)
setCreateLoading(false);
}
}
};
const handleEmployeeDeleted = async (employeeId: string) => {
try {
setDeletingEmployeeId(employeeId)
setDeletingEmployeeId(employeeId);
const { data } = await deleteEmployee({
variables: { id: employeeId }
})
variables: { id: employeeId },
});
if (data?.deleteEmployee) {
toast.success('Сотрудник успешно уволен')
refetch()
toast.success("Сотрудник успешно уволен");
refetch();
}
} catch (error) {
console.error('Error deleting employee:', error)
toast.error('Ошибка при увольнении сотрудника')
console.error("Error deleting employee:", error);
toast.error("Ошибка при увольнении сотрудника");
} finally {
setDeletingEmployeeId(null)
setDeletingEmployeeId(null);
}
}
};
// Функция для изменения статуса дня в табеле
const changeDayStatus = async (employeeId: string, day: number, currentStatus: string) => {
const changeDayStatus = async (
employeeId: string,
day: number,
currentStatus: string
) => {
try {
// Циклично переключаем статусы
const statuses = ['WORK', 'WEEKEND', 'VACATION', 'SICK', 'ABSENT']
const currentIndex = statuses.indexOf(currentStatus.toUpperCase())
const nextStatus = statuses[(currentIndex + 1) % statuses.length]
const statuses = ["WORK", "WEEKEND", "VACATION", "SICK", "ABSENT"];
const currentIndex = statuses.indexOf(currentStatus.toUpperCase());
const nextStatus = statuses[(currentIndex + 1) % statuses.length];
// Формируем дату
const date = new Date(currentYear, currentMonth, day)
const hours = nextStatus === 'WORK' ? 8 : 0
const date = new Date(currentYear, currentMonth, day);
const hours = nextStatus === "WORK" ? 8 : 0;
// Отправляем мутацию
await updateEmployeeSchedule({
variables: {
input: {
employeeId: employeeId,
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
status: nextStatus,
hoursWorked: hours
}
}
})
hoursWorked: hours,
},
},
});
// Обновляем локальное состояние
const updatedDate = new Date(currentYear, currentMonth, day)
const dateStr = updatedDate.toISOString().split('T')[0]
setEmployeeSchedules(prev => {
const currentSchedule = prev[employeeId] || []
const existingRecordIndex = currentSchedule.findIndex(record =>
record.date.split('T')[0] === dateStr
)
const newRecord = {
id: Date.now().toString(), // временный ID
date: updatedDate.toISOString(),
status: nextStatus,
hoursWorked: hours,
employee: { id: employeeId }
}
let updatedSchedule
// Обновляем локальное состояние
const updatedDate = new Date(currentYear, currentMonth, day);
const dateStr = updatedDate.toISOString().split("T")[0];
setEmployeeSchedules((prev) => {
const currentSchedule = prev[employeeId] || [];
const existingRecordIndex = currentSchedule.findIndex(
(record) => record.date.split("T")[0] === dateStr
);
const newRecord = {
id: Date.now().toString(), // временный ID
date: updatedDate.toISOString(),
status: nextStatus,
hoursWorked: hours,
employee: { id: employeeId },
};
let updatedSchedule;
if (existingRecordIndex >= 0) {
// Обновляем существующую запись
updatedSchedule = [...currentSchedule]
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
updatedSchedule = [...currentSchedule];
updatedSchedule[existingRecordIndex] = {
...updatedSchedule[existingRecordIndex],
...newRecord,
};
} else {
// Добавляем новую запись
updatedSchedule = [...currentSchedule, newRecord]
updatedSchedule = [...currentSchedule, newRecord];
}
return {
...prev,
[employeeId]: updatedSchedule
}
})
toast.success('Статус дня обновлен')
[employeeId]: updatedSchedule,
};
});
toast.success("Статус дня обновлен");
} catch (error) {
console.error('Error updating day status:', error)
toast.error('Ошибка при обновлении статуса дня')
console.error("Error updating day status:", error);
toast.error("Ошибка при обновлении статуса дня");
}
}
};
// Функция для обновления данных дня из модалки
const updateDayData = async (employeeId: string, date: Date, data: {
status: string
hoursWorked?: number
overtimeHours?: number
notes?: string
}) => {
const updateDayData = async (
employeeId: string,
date: Date,
data: {
status: string;
hoursWorked?: number;
overtimeHours?: number;
notes?: string;
}
) => {
try {
// Отправляем мутацию
await updateEmployeeSchedule({
variables: {
input: {
employeeId: employeeId,
date: date.toISOString().split('T')[0], // YYYY-MM-DD формат
date: date.toISOString().split("T")[0], // YYYY-MM-DD формат
status: data.status,
hoursWorked: data.hoursWorked,
overtimeHours: data.overtimeHours,
notes: data.notes
}
}
})
notes: data.notes,
},
},
});
// Обновляем локальное состояние
const dateStr = date.toISOString().split('T')[0]
setEmployeeSchedules(prev => {
const currentSchedule = prev[employeeId] || []
const existingRecordIndex = currentSchedule.findIndex(record =>
record.date.split('T')[0] === dateStr
)
const dateStr = date.toISOString().split("T")[0];
setEmployeeSchedules((prev) => {
const currentSchedule = prev[employeeId] || [];
const existingRecordIndex = currentSchedule.findIndex(
(record) => record.date.split("T")[0] === dateStr
);
const newRecord = {
id: Date.now().toString(), // временный ID
date: date.toISOString(),
@ -296,73 +329,98 @@ export function EmployeesDashboard() {
hoursWorked: data.hoursWorked,
overtimeHours: data.overtimeHours,
notes: data.notes,
employee: { id: employeeId }
}
let updatedSchedule
employee: { id: employeeId },
};
let updatedSchedule;
if (existingRecordIndex >= 0) {
// Обновляем существующую запись
updatedSchedule = [...currentSchedule]
updatedSchedule[existingRecordIndex] = { ...updatedSchedule[existingRecordIndex], ...newRecord }
updatedSchedule = [...currentSchedule];
updatedSchedule[existingRecordIndex] = {
...updatedSchedule[existingRecordIndex],
...newRecord,
};
} else {
// Добавляем новую запись
updatedSchedule = [...currentSchedule, newRecord]
updatedSchedule = [...currentSchedule, newRecord];
}
return {
...prev,
[employeeId]: updatedSchedule
}
})
toast.success('Данные дня обновлены')
[employeeId]: updatedSchedule,
};
});
toast.success("Данные дня обновлены");
} catch (error) {
console.error('Error updating day data:', error)
toast.error('Ошибка при обновлении данных дня')
console.error("Error updating day data:", error);
toast.error("Ошибка при обновлении данных дня");
}
}
};
const exportToCSV = () => {
const csvContent = [
['ФИО', 'Должность', 'Статус', 'Зарплата', 'Телефон', 'Email', 'Дата найма'],
[
"ФИО",
"Должность",
"Статус",
"Зарплата",
"Телефон",
"Email",
"Дата найма",
],
...employees.map((emp: Employee) => [
`${emp.firstName} ${emp.lastName}`,
emp.position,
emp.status === 'ACTIVE' ? 'Активен' :
emp.status === 'VACATION' ? 'В отпуске' :
emp.status === 'SICK' ? 'На больничном' : 'Уволен',
emp.salary?.toString() || '',
emp.status === "ACTIVE"
? "Активен"
: emp.status === "VACATION"
? "В отпуске"
: emp.status === "SICK"
? "На больничном"
: "Уволен",
emp.salary?.toString() || "",
emp.phone,
emp.email || '',
new Date(emp.hireDate).toLocaleDateString('ru-RU')
])
].map(row => row.join(',')).join('\n')
emp.email || "",
new Date(emp.hireDate).toLocaleDateString("ru-RU"),
]),
]
.map((row) => row.join(","))
.join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_report_${new Date().toISOString().split('T')[0]}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Отчет успешно экспортирован')
}
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`employees_report_${new Date().toISOString().split("T")[0]}.csv`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("Отчет успешно экспортирован");
};
const generateReport = () => {
const stats = {
total: employees.length,
active: employees.filter((e: Employee) => e.status === 'ACTIVE').length,
vacation: employees.filter((e: Employee) => e.status === 'VACATION').length,
sick: employees.filter((e: Employee) => e.status === 'SICK').length,
inactive: employees.filter((e: Employee) => e.status === 'FIRED').length,
avgSalary: Math.round(employees.reduce((sum: number, e: Employee) => sum + (e.salary || 0), 0) / employees.length)
}
active: employees.filter((e: Employee) => e.status === "ACTIVE").length,
vacation: employees.filter((e: Employee) => e.status === "VACATION")
.length,
sick: employees.filter((e: Employee) => e.status === "SICK").length,
inactive: employees.filter((e: Employee) => e.status === "FIRED").length,
avgSalary: Math.round(
employees.reduce(
(sum: number, e: Employee) => sum + (e.salary || 0),
0
) / employees.length
),
};
const reportText = `
ОТЧЕТ ПО СОТРУДНИКАМ
Дата: ${new Date().toLocaleDateString('ru-RU')}
Дата: ${new Date().toLocaleDateString("ru-RU")}
ОБЩАЯ СТАТИСТИКА:
Всего сотрудников: ${stats.total}
@ -370,24 +428,29 @@ export function EmployeesDashboard() {
В отпуске: ${stats.vacation}
На больничном: ${stats.sick}
• Неактивных: ${stats.inactive}
• Средняя зарплата: ${stats.avgSalary.toLocaleString('ru-RU')}
• Средняя зарплата: ${stats.avgSalary.toLocaleString("ru-RU")}
СПИСОК СОТРУДНИКОВ:
${employees.map((emp: Employee) =>
`${emp.firstName} ${emp.lastName} - ${emp.position}`
).join('\n')}
`.trim()
${employees
.map(
(emp: Employee) => `${emp.firstName} ${emp.lastName} - ${emp.position}`
)
.join("\n")}
`.trim();
const blob = new Blob([reportText], { type: 'text/plain;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `employees_summary_${new Date().toISOString().split('T')[0]}.txt`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
toast.success('Сводный отчет создан')
}
const blob = new Blob([reportText], { type: "text/plain;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute(
"download",
`employees_summary_${new Date().toISOString().split("T")[0]}.txt`
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("Сводный отчет создан");
};
if (loading) {
return (
@ -401,7 +464,7 @@ ${employees.map((emp: Employee) =>
</div>
</main>
</div>
)
);
}
return (
@ -410,19 +473,23 @@ ${employees.map((emp: Employee) =>
<main className="flex-1 ml-56 p-6">
<div className="max-w-7xl mx-auto">
{/* Панель управления с улучшенным расположением */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<div className="flex items-center justify-between gap-6 mb-6">
{/* Красивые табы слева */}
<TabsList className="glass-card inline-flex h-10 items-center justify-center rounded-lg bg-white/5 p-1">
<TabsTrigger
value="combined"
<TabsTrigger
value="combined"
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
>
<Users className="h-4 w-4 mr-2" />
Сотрудники
</TabsTrigger>
<TabsTrigger
value="reports"
<TabsTrigger
value="reports"
className="text-white data-[state=active]:bg-white/20 cursor-pointer text-sm px-4 py-2 rounded-md transition-all"
>
<FileText className="h-4 w-4 mr-2" />
@ -461,147 +528,166 @@ ${employees.map((emp: Employee) =>
)}
</Button>
)}
<Button
<Button
onClick={() => setShowAddForm(!showAddForm)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="h-4 w-4 mr-2" />
{showAddForm ? 'Скрыть форму' : 'Добавить'}
{showAddForm ? "Скрыть форму" : "Добавить"}
</Button>
</div>
</div>
{/* Форма добавления сотрудника */}
{showAddForm && (
showCompactForm ? (
<EmployeeCompactForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
{/* Форма добавления сотрудника */}
{showAddForm &&
(showCompactForm ? (
<EmployeeCompactForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
) : (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
))}
{/* Форма редактирования сотрудника */}
{showEditForm && editingEmployee && (
<EmployeeEditInlineForm
employee={editingEmployee}
onSave={handleEmployeeSaved}
onCancel={() => {
setShowEditForm(false);
setEditingEmployee(null);
}}
isLoading={createLoading}
/>
) : (
<EmployeeInlineForm
onSave={handleCreateEmployee}
onCancel={() => setShowAddForm(false)}
isLoading={createLoading}
/>
)
)}
)}
{/* Форма редактирования сотрудника */}
{showEditForm && editingEmployee && (
<EmployeeEditInlineForm
employee={editingEmployee}
onSave={handleEmployeeSaved}
onCancel={() => {
setShowEditForm(false)
setEditingEmployee(null)
}}
isLoading={createLoading}
/>
)}
{/* Контент табов */}
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter(
(employee: Employee) =>
`${employee.firstName} ${employee.lastName}`
.toLowerCase()
.includes(searchQuery.toLowerCase()) ||
employee.position
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
{/* Контент табов */}
<TabsContent value="combined">
<Card className="glass-card p-6">
{(() => {
const filteredEmployees = employees.filter((employee: Employee) =>
`${employee.firstName} ${employee.lastName}`.toLowerCase().includes(searchQuery.toLowerCase()) ||
employee.position.toLowerCase().includes(searchQuery.toLowerCase())
)
if (filteredEmployees.length === 0) {
return (
<EmployeeEmptyState
searchQuery={searchQuery}
onShowAddForm={() => setShowAddForm(true)}
/>
);
}
if (filteredEmployees.length === 0) {
return (
<EmployeeEmptyState
searchQuery={searchQuery}
onShowAddForm={() => setShowAddForm(true)}
/>
)
}
<div className="space-y-6">
{/* Навигация по месяцам и легенда в одной строке */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-white font-medium text-lg capitalize">
{new Date().toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</h3>
return (
<div className="space-y-6">
{/* Навигация по месяцам и легенда в одной строке */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h3 className="text-white font-medium text-lg capitalize">
{new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</h3>
{/* Кнопки навигации */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(currentYear, currentMonth - 1)
setCurrentYear(newDate.getFullYear())
setCurrentMonth(newDate.getMonth())
}}
>
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const today = new Date()
setCurrentYear(today.getFullYear())
setCurrentMonth(today.getMonth())
}}
>
Сегодня
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(currentYear, currentMonth + 1)
setCurrentYear(newDate.getFullYear())
setCurrentMonth(newDate.getMonth())
}}
>
</Button>
{/* Кнопки навигации */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(
currentYear,
currentMonth - 1
);
setCurrentYear(newDate.getFullYear());
setCurrentMonth(newDate.getMonth());
}}
>
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const today = new Date();
setCurrentYear(today.getFullYear());
setCurrentMonth(today.getMonth());
}}
>
Сегодня
</Button>
<Button
variant="outline"
size="sm"
className="glass-secondary text-white hover:text-white h-8 px-3"
onClick={() => {
const newDate = new Date(
currentYear,
currentMonth + 1
);
setCurrentYear(newDate.getFullYear());
setCurrentMonth(newDate.getMonth());
}}
>
</Button>
</div>
</div>
{/* Легенда статусов справа */}
<EmployeeLegend />
</div>
{/* Легенда статусов справа */}
<EmployeeLegend />
</div>
{/* Компактный список сотрудников с раскрывающимся табелем */}
<div>
{filteredEmployees.map((employee: Employee, index: number) => (
<div key={employee.id} className={index < filteredEmployees.length - 1 ? "mb-4" : ""}>
<EmployeeRow
employee={employee}
employeeSchedules={employeeSchedules}
currentYear={currentYear}
currentMonth={currentMonth}
onEdit={handleEditEmployee}
onDelete={handleEmployeeDeleted}
onDayStatusChange={changeDayStatus}
onDayUpdate={updateDayData}
deletingEmployeeId={deletingEmployeeId}
/>
</div>
))}
{/* Компактный список сотрудников с раскрывающимся табелем */}
<div>
{filteredEmployees.map(
(employee: Employee, index: number) => (
<div
key={employee.id}
className={
index < filteredEmployees.length - 1
? "mb-4"
: ""
}
>
<EmployeeRow
employee={employee}
employeeSchedules={employeeSchedules}
currentYear={currentYear}
currentMonth={currentMonth}
onEdit={handleEditEmployee}
onDelete={handleEmployeeDeleted}
onDayStatusChange={changeDayStatus}
onDayUpdate={updateDayData}
deletingEmployeeId={deletingEmployeeId}
/>
</div>
)
)}
</div>
</div>
</div>
)
})()}
</Card>
</TabsContent>
);
})()}
</Card>
</TabsContent>
<TabsContent value="reports">
<EmployeeReports
@ -615,5 +701,5 @@ ${employees.map((emp: Employee) =>
</div>
</main>
</div>
)
}
);
}

View File

@ -99,14 +99,44 @@ export function CreateFulfillmentConsumablesSupplyPage() {
GET_MY_COUNTERPARTIES
);
// ОТЛАДКА: Логируем состояние перед запросом товаров
console.log("🔍 ДИАГНОСТИКА ЗАПРОСА ТОВАРОВ:", {
selectedSupplier: selectedSupplier
? {
id: selectedSupplier.id,
name: selectedSupplier.name || selectedSupplier.fullName,
type: selectedSupplier.type,
}
: null,
skipQuery: !selectedSupplier,
productSearchQuery,
});
// Загружаем товары для выбранного поставщика
const { data: productsData, loading: productsLoading } = useQuery(
GET_ALL_PRODUCTS,
{
skip: !selectedSupplier,
variables: { search: productSearchQuery || null, category: null },
}
);
const {
data: productsData,
loading: productsLoading,
error: productsError,
} = useQuery(GET_ALL_PRODUCTS, {
skip: !selectedSupplier,
variables: { search: productSearchQuery || null, category: null },
onCompleted: (data) => {
console.log("✅ GET_ALL_PRODUCTS COMPLETED:", {
totalProducts: data?.allProducts?.length || 0,
products:
data?.allProducts?.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
orgId: p.organization?.id,
orgName: p.organization?.name,
})) || [],
});
},
onError: (error) => {
console.error("❌ GET_ALL_PRODUCTS ERROR:", error);
},
});
// Мутация для создания заказа поставки расходников
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
@ -117,9 +147,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
// Фильтруем только логистические компании
const logisticsPartners = (
counterpartiesData?.myCounterparties || []
).filter((org: FulfillmentConsumableSupplier) => org.type === "LOGIST");
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
(org: FulfillmentConsumableSupplier) => org.type === "LOGIST"
);
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = consumableSuppliers.filter(
@ -150,6 +180,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
}
: null,
productsLoading,
productsError: productsError?.message,
allProductsCount: productsData?.allProducts?.length || 0,
supplierProductsCount: supplierProducts.length,
allProducts:
@ -160,14 +191,20 @@ export function CreateFulfillmentConsumablesSupplyPage() {
organizationName: p.organization.name,
type: p.type || "NO_TYPE",
})) || [],
supplierProducts: supplierProducts.map((p) => ({
supplierProductsDetails: supplierProducts.slice(0, 5).map((p) => ({
id: p.id,
name: p.name,
organizationId: p.organization.id,
organizationName: p.organization.name,
})),
});
}, [selectedSupplier, productsData, productsLoading, supplierProducts]);
}, [
selectedSupplier,
productsData,
productsLoading,
productsError,
supplierProducts.length,
]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
@ -198,10 +235,13 @@ export function CreateFulfillmentConsumablesSupplyPage() {
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
if (quantity > 0) {
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0);
const availableStock =
(product.stock || product.quantity || 0) - (product.ordered || 0);
if (quantity > availableStock) {
toast.error(`❌ Недостаточно остатков!\оступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`);
toast.error(
`❌ Недостаточно остатков!\оступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`
);
return;
}
}
@ -265,7 +305,15 @@ export function CreateFulfillmentConsumablesSupplyPage() {
!deliveryDate ||
!selectedLogistics
) {
toast.error("Заполните все обязательные поля");
toast.error(
"Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика"
);
return;
}
// Дополнительная проверка ID логистики
if (!selectedLogistics.id) {
toast.error("Выберите логистическую компанию");
return;
}
@ -279,7 +327,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
deliveryDate: deliveryDate,
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
fulfillmentCenterId: user?.organization?.id,
logisticsPartnerId: selectedLogistics?.id,
logisticsPartnerId: selectedLogistics.id,
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
items: selectedConsumables.map((consumable) => ({
@ -574,15 +622,19 @@ export function CreateFulfillmentConsumablesSupplyPage() {
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
if (availableStock <= 0) {
return (
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
<div className="text-center">
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
<div className="text-red-400 font-bold text-xs">
НЕТ В НАЛИЧИИ
</div>
</div>
</div>
);
@ -636,10 +688,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
)}
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
if (availableStock <= 0) {
return (
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
@ -663,19 +717,26 @@ export function CreateFulfillmentConsumablesSupplyPage() {
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
<div className="text-right">
{(() => {
const totalStock = product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const totalStock =
product.stock ||
product.quantity ||
0;
const orderedStock =
product.ordered || 0;
const availableStock =
totalStock - orderedStock;
return (
<div className="flex flex-col items-end">
<span className={`text-xs font-medium ${
availableStock <= 0
? 'text-red-400'
: availableStock <= 10
? 'text-yellow-400'
: 'text-white/80'
}`}>
<span
className={`text-xs font-medium ${
availableStock <= 0
? "text-red-400"
: availableStock <= 10
? "text-yellow-400"
: "text-white/80"
}`}
>
Доступно: {availableStock}
</span>
{orderedStock > 0 && (
@ -693,10 +754,12 @@ export function CreateFulfillmentConsumablesSupplyPage() {
{/* Управление количеством */}
<div className="flex flex-col items-center space-y-2 mt-auto">
{(() => {
const totalStock = product.stock || product.quantity || 0;
const totalStock =
product.stock || product.quantity || 0;
const orderedStock = product.ordered || 0;
const availableStock = totalStock - orderedStock;
const availableStock =
totalStock - orderedStock;
return (
<div className="flex items-center space-x-2">
<Button
@ -713,81 +776,92 @@ export function CreateFulfillmentConsumablesSupplyPage() {
>
<Minus className="h-3 w-3" />
</Button>
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={
selectedQuantity === 0
? ""
: selectedQuantity.toString()
}
onChange={(e) => {
let inputValue = e.target.value;
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={
selectedQuantity === 0
? ""
: selectedQuantity.toString()
}
onChange={(e) => {
let inputValue = e.target.value;
// Удаляем все нецифровые символы
inputValue = inputValue.replace(
/[^0-9]/g,
""
);
// Удаляем все нецифровые символы
inputValue = inputValue.replace(
/[^0-9]/g,
""
);
// Удаляем ведущие нули
inputValue = inputValue.replace(
/^0+/,
""
);
// Удаляем ведущие нули
inputValue = inputValue.replace(
/^0+/,
""
);
// Если строка пустая после удаления нулей, устанавливаем 0
const numericValue =
inputValue === ""
? 0
: parseInt(inputValue);
// Если строка пустая после удаления нулей, устанавливаем 0
const numericValue =
inputValue === ""
? 0
: parseInt(inputValue);
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(
numericValue,
availableStock,
99999
);
// Ограничиваем значение максимумом доступного остатка
const clampedValue = Math.min(
numericValue,
availableStock,
99999
);
updateConsumableQuantity(
product.id,
clampedValue
);
}}
onBlur={(e) => {
// При потере фокуса, если поле пустое, устанавливаем 0
if (e.target.value === "") {
updateConsumableQuantity(
product.id,
0
);
}
}}
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
placeholder="0"
/>
updateConsumableQuantity(
product.id,
clampedValue
);
}}
onBlur={(e) => {
// При потере фокуса, если поле пустое, устанавливаем 0
if (e.target.value === "") {
updateConsumableQuantity(
product.id,
0
);
}
}}
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
placeholder="0"
/>
<Button
variant="ghost"
size="sm"
onClick={() =>
updateConsumableQuantity(
product.id,
Math.min(selectedQuantity + 1, availableStock, 99999)
Math.min(
selectedQuantity + 1,
availableStock,
99999
)
)
}
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
selectedQuantity >= availableStock || availableStock <= 0
? 'text-white/30 cursor-not-allowed'
: 'text-white/60 hover:text-white hover:bg-white/20'
selectedQuantity >=
availableStock ||
availableStock <= 0
? "text-white/30 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/20"
}`}
disabled={selectedQuantity >= availableStock || availableStock <= 0}
disabled={
selectedQuantity >=
availableStock ||
availableStock <= 0
}
title={
availableStock <= 0
? 'Товар отсутствует на складе'
: selectedQuantity >= availableStock
? `Максимум доступно: ${availableStock}`
: 'Увеличить количество'
availableStock <= 0
? "Товар отсутствует на складе"
: selectedQuantity >=
availableStock
? `Максимум доступно: ${availableStock}`
: "Увеличить количество"
}
>
<Plus className="h-3 w-3" />
@ -903,7 +977,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
value={selectedLogistics?.id || ""}
onChange={(e) => {
const logisticsId = e.target.value;
const logistics = logisticsPartners.find(p => p.id === logisticsId);
const logistics = logisticsPartners.find(
(p) => p.id === logisticsId
);
setSelectedLogistics(logistics || null);
}}
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
@ -912,8 +988,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
Выберите логистику
</option>
{logisticsPartners.map((partner) => (
<option
key={partner.id}
<option
key={partner.id}
value={partner.id}
className="bg-gray-800 text-white"
>
@ -922,8 +998,18 @@ export function CreateFulfillmentConsumablesSupplyPage() {
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<svg
className="w-4 h-4 text-white/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>

View File

@ -1,22 +1,34 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { useQuery } from "@apollo/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { GET_PENDING_SUPPLIES_COUNT } from "@/graphql/queries";
import { Building2, ShoppingCart } from "lucide-react";
import {
Building2,
ShoppingCart,
Package,
Wrench,
RotateCcw,
Clock,
FileText,
CheckCircle,
} from "lucide-react";
// Импорты компонентов подразделов
import { FulfillmentSuppliesTab } from "./fulfillment-supplies/fulfillment-supplies-tab";
import { MarketplaceSuppliesTab } from "./marketplace-supplies/marketplace-supplies-tab";
import { FulfillmentDetailedSuppliesTab } from "./fulfillment-supplies/fulfillment-detailed-supplies-tab";
import { FulfillmentConsumablesOrdersTab } from "./fulfillment-supplies/fulfillment-consumables-orders-tab";
import { PvzReturnsTab } from "./fulfillment-supplies/pvz-returns-tab";
// Компонент для отображения бейджа с уведомлениями
function NotificationBadge({ count }: { count: number }) {
if (count === 0) return null;
return (
<div className="ml-1 bg-red-500 text-white text-xs font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1">
{count > 99 ? "99+" : count}
@ -27,72 +39,390 @@ function NotificationBadge({ count }: { count: number }) {
export function FulfillmentSuppliesDashboard() {
const { getSidebarMargin } = useSidebar();
const [activeTab, setActiveTab] = useState("fulfillment");
const [activeSubTab, setActiveSubTab] = useState("goods"); // товар
const [activeThirdTab, setActiveThirdTab] = useState("new"); // новые
// Загружаем данные о непринятых поставках
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const { data: pendingData, error: pendingError } = useQuery(
GET_PENDING_SUPPLIES_COUNT,
{
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
onError: (error) => {
console.error("❌ GET_PENDING_SUPPLIES_COUNT Error:", error);
},
}
);
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
// Логируем ошибку для диагностики
React.useEffect(() => {
if (pendingError) {
console.error("🚨 Ошибка загрузки счетчиков поставок:", pendingError);
}
}, [pendingError]);
// ✅ ПРАВИЛЬНО: Для фулфилмента считаем только поставки, НЕ заявки на партнерство
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
const ourSupplyOrdersCount =
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
const sellerSupplyOrdersCount =
pendingData?.pendingSuppliesCount?.sellerSupplyOrders || 0;
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main
className={`flex-1 ${getSidebarMargin()} px-2 xl:px-4 py-2 xl:py-3 overflow-hidden transition-all duration-300`}
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
>
<div className="h-full w-full flex flex-col">
{/* Основной контент с табами */}
<div className="flex-1 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-2 bg-white/5 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10">
<TabsTrigger
value="fulfillment"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-xs xl:text-sm relative"
<div className="h-full w-full flex flex-col space-y-4">
{/* БЛОК 1: ТАБЫ ВСЕХ УРОВНЕЙ */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
{/* УРОВЕНЬ 1: Главные табы */}
<div className="mb-4">
<div className="grid w-full grid-cols-2 bg-white/15 backdrop-blur border-white/30 rounded-xl h-11 p-2">
<button
onClick={() => setActiveTab("fulfillment")}
className={`flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 ${
activeTab === "fulfillment"
? "bg-gradient-to-r from-purple-500/40 to-pink-500/40 text-white shadow-lg"
: "text-white/80 hover:text-white"
}`}
>
<Building2 className="h-3 w-3" />
<Building2 className="h-4 w-4" />
<span className="hidden sm:inline">
Поставки на фулфилмент
</span>
<span className="sm:hidden">Фулфилмент</span>
<NotificationBadge count={pendingCount} />
</TabsTrigger>
<TabsTrigger
value="marketplace"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-xs xl:text-sm"
</button>
<button
onClick={() => setActiveTab("marketplace")}
className={`flex items-center gap-2 text-sm font-semibold transition-all duration-200 rounded-lg px-3 ${
activeTab === "marketplace"
? "bg-gradient-to-r from-purple-500/40 to-pink-500/40 text-white shadow-lg"
: "text-white/80 hover:text-white"
}`}
>
<ShoppingCart className="h-3 w-3" />
<ShoppingCart className="h-4 w-4" />
<span className="hidden sm:inline">
Поставки на маркетплейсы
</span>
<span className="sm:hidden">Маркетплейсы</span>
</TabsTrigger>
</TabsList>
</button>
</div>
</div>
<TabsContent
value="fulfillment"
className="flex-1 overflow-hidden mt-2 xl:mt-3"
>
<Card className="glass-card h-full overflow-hidden p-0">
<FulfillmentSuppliesTab />
</Card>
</TabsContent>
{/* УРОВЕНЬ 2: Подтабы */}
{activeTab === "fulfillment" && (
<div className="ml-4 mb-3">
<div className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1">
<button
onClick={() => setActiveSubTab("goods")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === "goods"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<Package className="h-3 w-3" />
<span className="hidden sm:inline">Товар</span>
<span className="sm:hidden">Т</span>
</button>
<button
onClick={() => setActiveSubTab("detailed-supplies")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
activeSubTab === "detailed-supplies"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<Building2 className="h-3 w-3" />
<span className="hidden md:inline">
Расходники фулфилмента
</span>
<span className="md:hidden hidden sm:inline">
Фулфилмент
</span>
<span className="sm:hidden">Ф</span>
<NotificationBadge count={ourSupplyOrdersCount} />
</button>
<button
onClick={() => setActiveSubTab("consumables")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 relative ${
activeSubTab === "consumables"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<Wrench className="h-3 w-3" />
<span className="hidden md:inline">
Расходники селлеров
</span>
<span className="md:hidden hidden sm:inline">Селлеры</span>
<span className="sm:hidden">С</span>
<NotificationBadge count={sellerSupplyOrdersCount} />
</button>
<button
onClick={() => setActiveSubTab("returns")}
className={`flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md px-2 ${
activeSubTab === "returns"
? "bg-white/15 text-white border-white/20"
: "text-white/60 hover:text-white/80"
}`}
>
<RotateCcw className="h-3 w-3" />
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
<span className="sm:hidden">В</span>
</button>
</div>
</div>
)}
<TabsContent
value="marketplace"
className="flex-1 overflow-hidden mt-2 xl:mt-3"
>
<Card className="glass-card h-full overflow-hidden p-0">
<MarketplaceSuppliesTab />
</Card>
</TabsContent>
</Tabs>
{/* УРОВЕНЬ 3: Подподтабы */}
{activeTab === "fulfillment" && activeSubTab === "goods" && (
<div className="ml-8">
<div className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1">
<button
onClick={() => setActiveThirdTab("new")}
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === "new"
? "bg-white/10 text-white"
: "text-white/50 hover:text-white/70"
}`}
>
<Clock className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Новые</span>
<span className="sm:hidden">Н</span>
</button>
<button
onClick={() => setActiveThirdTab("receiving")}
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === "receiving"
? "bg-white/10 text-white"
: "text-white/50 hover:text-white/70"
}`}
>
<FileText className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Приёмка</span>
<span className="sm:hidden">П</span>
</button>
<button
onClick={() => setActiveThirdTab("received")}
className={`flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm px-2 ${
activeThirdTab === "received"
? "bg-white/10 text-white"
: "text-white/50 hover:text-white/70"
}`}
>
<CheckCircle className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Принято</span>
<span className="sm:hidden">Пр</span>
</button>
</div>
</div>
)}
</div>
{/* БЛОК 2: МОДУЛИ СТАТИСТИКИ */}
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
<h3 className="text-white font-semibold mb-4">Статистика</h3>
{/* Статистика для расходников фулфилмента */}
{activeTab === "fulfillment" &&
activeSubTab === "detailed-supplies" && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Building2 className="h-5 w-5 text-blue-400" />
<div>
<p className="text-xs text-white/60">Наши заказы</p>
<p className="text-lg font-semibold text-white">
{ourSupplyOrdersCount}
</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Package className="h-5 w-5 text-green-400" />
<div>
<p className="text-xs text-white/60">Всего позиций</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Wrench className="h-5 w-5 text-purple-400" />
<div>
<p className="text-xs text-white/60">На складе</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-emerald-400" />
<div>
<p className="text-xs text-white/60">Доставлено</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
</div>
)}
{/* Статистика для расходников селлеров */}
{activeTab === "fulfillment" && activeSubTab === "consumables" && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Wrench className="h-5 w-5 text-orange-400" />
<div>
<p className="text-xs text-white/60">От селлеров</p>
<p className="text-lg font-semibold text-white">
{sellerSupplyOrdersCount}
</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Clock className="h-5 w-5 text-yellow-400" />
<div>
<p className="text-xs text-white/60">В обработке</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Package className="h-5 w-5 text-blue-400" />
<div>
<p className="text-xs text-white/60">Принято</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<div>
<p className="text-xs text-white/60">Использовано</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
</div>
)}
{/* Статистика для товаров */}
{activeTab === "fulfillment" && activeSubTab === "goods" && (
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<Clock className="h-5 w-5 text-blue-400" />
<div>
<p className="text-xs text-white/60">Новые</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<FileText className="h-5 w-5 text-yellow-400" />
<div>
<p className="text-xs text-white/60">Приёмка</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
<div className="bg-white/5 backdrop-blur rounded-lg p-4">
<div className="flex items-center space-x-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<div>
<p className="text-xs text-white/60">Принято</p>
<p className="text-lg font-semibold text-white">-</p>
</div>
</div>
</div>
</div>
)}
{/* Общая статистика для других разделов */}
{activeTab === "fulfillment" && activeSubTab === "returns" && (
<div className="text-white/70">Статистика возвратов с ПВЗ</div>
)}
{activeTab === "marketplace" && (
<div className="text-white/70">
Статистика поставок на маркетплейсы
</div>
)}
</div>
{/* БЛОК 3: ОСНОВНОЙ КОНТЕНТ */}
<div className="flex-1 overflow-hidden">
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl h-full overflow-hidden p-6">
<div className="h-full">
<h3 className="text-white font-semibold mb-4">
Контент: {activeTab} {activeSubTab} {activeThirdTab}
</h3>
{/* КОНТЕНТ ДЛЯ ТОВАРОВ */}
{activeTab === "fulfillment" &&
activeSubTab === "goods" &&
activeThirdTab === "new" && (
<div className="text-white/80">
Здесь отображаются НОВЫЕ поставки товаров на фулфилмент
</div>
)}
{activeTab === "fulfillment" &&
activeSubTab === "goods" &&
activeThirdTab === "receiving" && (
<div className="text-white/80">
Здесь отображаются товары в ПРИЁМКЕ
</div>
)}
{activeTab === "fulfillment" &&
activeSubTab === "goods" &&
activeThirdTab === "received" && (
<div className="text-white/80">
Здесь отображаются ПРИНЯТЫЕ товары
</div>
)}
{/* КОНТЕНТ ДЛЯ РАСХОДНИКОВ ФУЛФИЛМЕНТА */}
{activeTab === "fulfillment" &&
activeSubTab === "detailed-supplies" && (
<div className="h-full overflow-hidden">
<FulfillmentDetailedSuppliesTab />
</div>
)}
{/* КОНТЕНТ ДЛЯ РАСХОДНИКОВ СЕЛЛЕРОВ */}
{activeTab === "fulfillment" &&
activeSubTab === "consumables" && (
<div className="h-full overflow-hidden">
<FulfillmentConsumablesOrdersTab />
</div>
)}
{/* КОНТЕНТ ДЛЯ ВОЗВРАТОВ С ПВЗ */}
{activeTab === "fulfillment" && activeSubTab === "returns" && (
<div className="h-full overflow-hidden">
<PvzReturnsTab />
</div>
)}
{/* КОНТЕНТ ДЛЯ МАРКЕТПЛЕЙСОВ */}
{activeTab === "marketplace" && (
<div className="text-white/80">
Содержимое поставок на маркетплейсы
</div>
)}
</div>
</div>
</div>
</div>
</main>

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import React, { useState } from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -12,8 +12,14 @@ import {
GET_MY_SUPPLIES,
GET_PENDING_SUPPLIES_COUNT,
GET_WAREHOUSE_PRODUCTS,
GET_MY_EMPLOYEES,
GET_LOGISTICS_PARTNERS,
} from "@/graphql/queries";
import { UPDATE_SUPPLY_ORDER_STATUS } from "@/graphql/mutations";
import {
UPDATE_SUPPLY_ORDER_STATUS,
ASSIGN_LOGISTICS_TO_SUPPLY,
FULFILLMENT_RECEIVE_ORDER,
} from "@/graphql/mutations";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import {
@ -34,16 +40,36 @@ import {
Store,
Bell,
AlertTriangle,
UserPlus,
Settings,
} from "lucide-react";
interface SupplyOrder {
id: string;
partnerId: string;
deliveryDate: string;
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
status:
| "PENDING"
| "SUPPLIER_APPROVED"
| "CONFIRMED"
| "LOGISTICS_CONFIRMED"
| "SHIPPED"
| "IN_TRANSIT"
| "DELIVERED"
| "CANCELLED";
totalAmount: number;
totalItems: number;
createdAt: string;
fulfillmentCenter?: {
id: string;
name: string;
fullName: string;
};
organization?: {
id: string;
name: string;
fullName: string;
};
partner: {
id: string;
inn: string;
@ -83,31 +109,106 @@ interface SupplyOrder {
export function FulfillmentConsumablesOrdersTab() {
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [assigningOrders, setAssigningOrders] = useState<Set<string>>(
new Set()
);
const [selectedLogistics, setSelectedLogistics] = useState<{
[orderId: string]: string;
}>({});
const [selectedEmployees, setSelectedEmployees] = useState<{
[orderId: string]: string;
}>({});
const { user } = useAuth();
// Запросы данных
const {
data: employeesData,
loading: employeesLoading,
error: employeesError,
} = useQuery(GET_MY_EMPLOYEES);
const {
data: logisticsData,
loading: logisticsLoading,
error: logisticsError,
} = useQuery(GET_LOGISTICS_PARTNERS);
// Отладочная информация
console.log("DEBUG EMPLOYEES:", {
loading: employeesLoading,
error: employeesError?.message,
errorDetails: employeesError,
data: employeesData,
employees: employeesData?.myEmployees,
});
console.log("DEBUG LOGISTICS:", {
loading: logisticsLoading,
error: logisticsError?.message,
errorDetails: logisticsError,
data: logisticsData,
partners: logisticsData?.logisticsPartners,
});
// Логируем ошибки отдельно
if (employeesError) {
console.error("EMPLOYEES ERROR:", employeesError);
}
if (logisticsError) {
console.error("LOGISTICS ERROR:", logisticsError);
}
// Загружаем заказы поставок
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS);
// Мутация для обновления статуса заказа
const [updateSupplyOrderStatus, { loading: updating }] = useMutation(
UPDATE_SUPPLY_ORDER_STATUS,
// Мутация для приемки поставки фулфилментом
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(
FULFILLMENT_RECEIVE_ORDER,
{
onCompleted: (data) => {
if (data.updateSupplyOrderStatus.success) {
toast.success(data.updateSupplyOrderStatus.message);
if (data.fulfillmentReceiveOrder.success) {
toast.success(data.fulfillmentReceiveOrder.message);
refetch(); // Обновляем список заказов
} else {
toast.error(data.updateSupplyOrderStatus.message);
toast.error(data.fulfillmentReceiveOrder.message);
}
},
refetchQueries: [
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
],
onError: (error) => {
console.error("Error updating supply order status:", error);
toast.error("Ошибка при обновлении статуса заказа");
console.error("Error receiving supply order:", error);
toast.error("Ошибка при приеме заказа поставки");
},
}
);
// Мутация для назначения логистики и ответственного
const [assignLogisticsToSupply, { loading: assigning }] = useMutation(
ASSIGN_LOGISTICS_TO_SUPPLY,
{
onCompleted: (data) => {
if (data.assignLogisticsToSupply.success) {
toast.success("Логистика и ответственный назначены успешно");
refetch(); // Обновляем список заказов
// Сбрасываем состояние назначения
setAssigningOrders((prev) => {
const newSet = new Set(prev);
newSet.delete(data.assignLogisticsToSupply.supplyOrder.id);
return newSet;
});
} else {
toast.error(
data.assignLogisticsToSupply.message ||
"Ошибка при назначении логистики"
);
}
},
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
onError: (error) => {
console.error("Error assigning logistics:", error);
toast.error("Ошибка при назначении логистики");
},
}
);
@ -144,6 +245,19 @@ export function FulfillmentConsumablesOrdersTab() {
number: fulfillmentOrders.length - index, // Обратный порядок для новых заказов сверху
}));
// Автоматически открываем режим назначения для заказов, которые требуют назначения логистики
React.useEffect(() => {
fulfillmentOrders.forEach((order) => {
if (canAssignLogistics(order) && !assigningOrders.has(order.id)) {
setAssigningOrders((prev) => {
const newSet = new Set(prev);
newSet.add(order.id);
return newSet;
});
}
});
}, [fulfillmentOrders]);
const getStatusBadge = (status: SupplyOrder["status"]) => {
const statusMap = {
PENDING: {
@ -151,11 +265,26 @@ export function FulfillmentConsumablesOrdersTab() {
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
icon: Clock,
},
CONFIRMED: {
label: "Подтверждена",
SUPPLIER_APPROVED: {
label: "Одобрено",
color: "bg-green-500/20 text-green-300 border-green-500/30",
icon: CheckCircle,
},
CONFIRMED: {
label: "Подтверждена",
color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
icon: CheckCircle,
},
LOGISTICS_CONFIRMED: {
label: "Логистика OK",
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
icon: Truck,
},
SHIPPED: {
label: "Отгружено",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
icon: Package,
},
IN_TRANSIT: {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
@ -181,24 +310,69 @@ export function FulfillmentConsumablesOrdersTab() {
);
};
const handleStatusUpdate = async (
orderId: string,
newStatus: SupplyOrder["status"]
) => {
// Функция для приема заказа фулфилментом
const handleReceiveOrder = async (orderId: string) => {
try {
await updateSupplyOrderStatus({
variables: {
id: orderId,
status: newStatus,
},
await fulfillmentReceiveOrder({
variables: { id: orderId },
});
} catch (error) {
console.error("Error updating status:", error);
console.error("Error receiving order:", error);
}
};
const canMarkAsDelivered = (status: SupplyOrder["status"]) => {
return status === "IN_TRANSIT";
// Проверяем, можно ли принять заказ (для фулфилмента)
const canReceiveOrder = (status: SupplyOrder["status"]) => {
return status === "SHIPPED";
};
const toggleAssignmentMode = (orderId: string) => {
setAssigningOrders((prev) => {
const newSet = new Set(prev);
if (newSet.has(orderId)) {
newSet.delete(orderId);
} else {
newSet.add(orderId);
}
return newSet;
});
};
const handleAssignLogistics = async (orderId: string) => {
const logisticsId = selectedLogistics[orderId];
const employeeId = selectedEmployees[orderId];
if (!logisticsId) {
toast.error("Выберите логистическую компанию");
return;
}
if (!employeeId) {
toast.error("Выберите ответственного сотрудника");
return;
}
try {
await assignLogisticsToSupply({
variables: {
supplyOrderId: orderId,
logisticsPartnerId: logisticsId,
responsibleId: employeeId,
},
});
} catch (error) {
console.error("Error assigning logistics:", error);
}
};
const canAssignLogistics = (order: SupplyOrder) => {
// Можем назначать логистику если:
// 1. Статус SUPPLIER_APPROVED (одобрено поставщиком) или CONFIRMED (подтвержден фулфилментом)
// 2. Логистика еще не назначена
return (
(order.status === "SUPPLIER_APPROVED" || order.status === "CONFIRMED") &&
!order.logisticsPartner
);
};
const formatDate = (dateString: string) => {
@ -247,15 +421,15 @@ export function FulfillmentConsumablesOrdersTab() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
<div className="flex items-center space-x-2">
<div className="p-1 bg-blue-500/20 rounded">
<Clock className="h-3 w-3 text-blue-400" />
<div className="p-1 bg-green-500/20 rounded">
<CheckCircle className="h-3 w-3 text-green-400" />
</div>
<div>
<p className="text-white/60 text-xs">Ожидание</p>
<p className="text-white/60 text-xs">Одобрено</p>
<p className="text-sm font-bold text-white">
{
fulfillmentOrders.filter(
(order) => order.status === "PENDING"
(order) => order.status === "SUPPLIER_APPROVED"
).length
}
</p>
@ -265,8 +439,8 @@ export function FulfillmentConsumablesOrdersTab() {
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
<div className="flex items-center space-x-2">
<div className="p-1 bg-green-500/20 rounded">
<CheckCircle className="h-3 w-3 text-green-400" />
<div className="p-1 bg-emerald-500/20 rounded">
<CheckCircle className="h-3 w-3 text-emerald-400" />
</div>
<div>
<p className="text-white/60 text-xs">Подтверждено</p>
@ -336,8 +510,18 @@ export function FulfillmentConsumablesOrdersTab() {
ordersWithNumbers.map((order) => (
<Card
key={order.id}
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
onClick={() => toggleOrderExpansion(order.id)}
className={`bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors ${
canAssignLogistics(order) && assigningOrders.has(order.id)
? "cursor-default"
: "cursor-pointer"
}`}
onClick={() => {
if (
!(canAssignLogistics(order) && assigningOrders.has(order.id))
) {
toggleOrderExpansion(order.id);
}
}}
>
{/* Компактная основная информация */}
<div className="px-3 py-2">
@ -427,53 +611,20 @@ export function FulfillmentConsumablesOrdersTab() {
{/* Правая часть - статус и действия */}
<div className="flex items-center space-x-2 flex-shrink-0">
<Badge
className={`${
order.status === "PENDING"
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
: order.status === "CONFIRMED"
? "bg-green-500/20 text-green-300 border-green-500/30"
: order.status === "IN_TRANSIT"
? "bg-yellow-500/20 text-yellow-300 border-yellow-500/30"
: order.status === "DELIVERED"
? "bg-purple-500/20 text-purple-300 border-purple-500/30"
: "bg-red-500/20 text-red-300 border-red-500/30"
} border flex items-center gap-1 text-xs px-2 py-1`}
>
{order.status === "PENDING" && (
<Clock className="h-3 w-3" />
)}
{order.status === "CONFIRMED" && (
<CheckCircle className="h-3 w-3" />
)}
{order.status === "IN_TRANSIT" && (
<Truck className="h-3 w-3" />
)}
{order.status === "DELIVERED" && (
<Package className="h-3 w-3" />
)}
{order.status === "CANCELLED" && (
<XCircle className="h-3 w-3" />
)}
{order.status === "PENDING" && "Ожидание"}
{order.status === "CONFIRMED" && "Подтверждена"}
{order.status === "IN_TRANSIT" && "В пути"}
{order.status === "DELIVERED" && "Доставлена"}
{order.status === "CANCELLED" && "Отменена"}
</Badge>
{getStatusBadge(order.status)}
{canMarkAsDelivered(order.status) && (
{canReceiveOrder(order.status) && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStatusUpdate(order.id, "DELIVERED");
handleReceiveOrder(order.id);
}}
disabled={updating}
disabled={receiving}
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-7"
>
<CheckCircle className="h-3 w-3 mr-1" />
Получено
Принять
</Button>
)}
</div>
@ -528,6 +679,100 @@ export function FulfillmentConsumablesOrdersTab() {
</div>
</div>
{/* Назначение логистики и ответственного в одной строке */}
{assigningOrders.has(order.id) && canAssignLogistics(order) && (
<div className="mt-2 p-2 bg-blue-500/10 border border-blue-500/20 rounded">
<div className="flex items-center gap-3">
{/* Иконка и заголовок */}
<div className="flex items-center text-blue-300 text-xs font-medium whitespace-nowrap">
<Settings className="h-3 w-3 mr-1" />
Назначить:
</div>
{/* Выбор логистики */}
<div className="flex-1 min-w-0">
<select
value={selectedLogistics[order.id] || ""}
onChange={(e) => {
setSelectedLogistics((prev) => ({
...prev,
[order.id]: e.target.value,
}));
}}
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
>
<option value="" className="bg-gray-800 text-white">
{logisticsData?.logisticsPartners?.length > 0
? "Выберите логистику"
: "Нет логистики"}
</option>
{logisticsData?.logisticsPartners?.map(
(logistics: any) => (
<option
key={logistics.id}
value={logistics.id}
className="bg-gray-800 text-white"
>
{logistics.name || logistics.fullName}
</option>
)
) || []}
</select>
</div>
{/* Выбор ответственного */}
<div className="flex-1 min-w-0">
<select
value={selectedEmployees[order.id] || ""}
onChange={(e) => {
setSelectedEmployees((prev) => ({
...prev,
[order.id]: e.target.value,
}));
}}
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
>
<option value="" className="bg-gray-800 text-white">
{employeesData?.myEmployees?.length > 0
? "Выберите ответственного"
: "Нет сотрудников"}
</option>
{employeesData?.myEmployees?.map((employee: any) => (
<option
key={employee.id}
value={employee.id}
className="bg-gray-800 text-white"
>
{employee.fullName || employee.name}
</option>
)) || []}
</select>
</div>
{/* Кнопки действий */}
<div className="flex gap-1 flex-shrink-0">
<Button
size="sm"
onClick={() => handleAssignLogistics(order.id)}
disabled={assigning}
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-6"
>
<UserPlus className="h-3 w-3 mr-1" />
Принять
</Button>
<Button
size="sm"
variant="outline"
onClick={() => toggleAssignmentMode(order.id)}
className="border-white/20 text-white/60 hover:bg-white/10 text-xs px-2 py-1 h-6"
>
</Button>
</div>
</div>
</div>
)}
{/* Развернутые детали заказа */}
{expandedOrders.has(order.id) && (
<>

View File

@ -33,8 +33,6 @@ import {
CheckCircle,
} from "lucide-react";
// Интерфейс для заказа
interface SupplyOrder {
id: string;
@ -174,10 +172,14 @@ export function FulfillmentDetailedSuppliesTab() {
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
// Критерии: создатель = мы И получатель = мы (ОБА условия)
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
(order: SupplyOrder) => {
(order: any) => {
// Защита от null/undefined значений
return (
order.organizationId === currentOrganizationId && // Создали мы
order.fulfillmentCenterId === currentOrganizationId // Получатель - мы
order?.organizationId === currentOrganizationId && // Создали мы
order?.fulfillmentCenterId === currentOrganizationId && // Получатель - мы
order?.organization && // Проверяем наличие organization
order?.partner && // Проверяем наличие partner
Array.isArray(order?.items) // Проверяем наличие items
);
}
);
@ -248,7 +250,9 @@ export function FulfillmentDetailedSuppliesTab() {
{/* Заголовок с кнопкой создания поставки */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
<h2 className="text-xl font-bold text-white mb-1">
Расходники фулфилмента
</h2>
<p className="text-white/60 text-sm">
Поставки расходников, поступающие на склад фулфилмент-центра
</p>

View File

@ -40,8 +40,6 @@ import {
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Интерфейсы для данных
interface Employee {
id: string;
@ -662,52 +660,50 @@ export function FulfillmentGoodsTab() {
};
return (
<div className="h-full flex flex-col p-2 xl:p-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
{/* Вкладки товаров */}
<TabsList className="grid w-full grid-cols-3 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-4">
<TabsTrigger
value="new"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
>
<Clock className="h-3 w-3 xl:h-4 xl:w-4" />
<span className="hidden sm:inline">Новые</span>
<span className="sm:hidden">Н</span>
</TabsTrigger>
<TabsTrigger
value="receiving"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
>
<FileText className="h-3 w-3 xl:h-4 xl:w-4" />
<span className="hidden sm:inline">Приёмка</span>
<span className="sm:hidden">П</span>
</TabsTrigger>
<TabsTrigger
value="received"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
>
<CheckCircle className="h-3 w-3 xl:h-4 xl:w-4" />
<span className="hidden sm:inline">Принято</span>
<span className="sm:hidden">Пр</span>
</TabsTrigger>
</TabsList>
<div className="space-y-3">
{/* УРОВЕНЬ 3: Подподтабы (маленький размер, больший отступ) */}
<div className="ml-8">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1 mb-3">
<TabsTrigger
value="new"
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
>
<Clock className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Новые</span>
<span className="sm:hidden">Н</span>
</TabsTrigger>
<TabsTrigger
value="receiving"
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
>
<FileText className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Приёмка</span>
<span className="sm:hidden">П</span>
</TabsTrigger>
<TabsTrigger
value="received"
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
>
<CheckCircle className="h-2.5 w-2.5" />
<span className="hidden sm:inline">Принято</span>
<span className="sm:hidden">Пр</span>
</TabsTrigger>
</TabsList>
<TabsContent value="new" className="flex-1 overflow-hidden">
<TabContent tabName="new" />
</TabsContent>
<TabsContent value="new" className="space-y-0">
<TabContent tabName="new" />
</TabsContent>
<TabsContent value="receiving" className="flex-1 overflow-hidden">
<TabContent tabName="receiving" />
</TabsContent>
<TabsContent value="receiving" className="space-y-0">
<TabContent tabName="receiving" />
</TabsContent>
<TabsContent value="received" className="flex-1 overflow-hidden">
<TabContent tabName="received" />
</TabsContent>
</Tabs>
<TabsContent value="received" className="space-y-0">
<TabContent tabName="received" />
</TabsContent>
</Tabs>
</div>
</div>
);

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useQuery } from "@apollo/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -32,13 +32,33 @@ export function FulfillmentSuppliesTab() {
const [activeTab, setActiveTab] = useState("goods");
// Загружаем данные о непринятых поставках
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
});
const { data: pendingData, error: pendingError } = useQuery(
GET_PENDING_SUPPLIES_COUNT,
{
pollInterval: 30000, // Обновляем каждые 30 секунд
fetchPolicy: "cache-first",
errorPolicy: "ignore",
onError: (error) => {
console.error(
"❌ GET_PENDING_SUPPLIES_COUNT Error in FulfillmentSuppliesTab:",
error
);
},
}
);
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
// Логируем ошибку для диагностики
React.useEffect(() => {
if (pendingError) {
console.error(
"🚨 Ошибка загрузки счетчиков в FulfillmentSuppliesTab:",
pendingError
);
}
}, [pendingError]);
// ✅ ПРАВИЛЬНО: Для фулфилмента считаем только поставки, НЕ заявки на партнерство
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
const ourSupplyOrdersCount =
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
const sellerSupplyOrdersCount =
@ -66,74 +86,74 @@ export function FulfillmentSuppliesTab() {
};
return (
<div className="h-full flex flex-col">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-4 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-3 mx-2 xl:mx-4 mt-2 xl:mt-4">
<TabsTrigger
value="goods"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs"
>
<Package className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
<span className="hidden sm:inline">Товар</span>
<span className="sm:hidden">Т</span>
</TabsTrigger>
<TabsTrigger
value="detailed-supplies"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
>
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
<span className="hidden md:inline">Расходники фулфилмента</span>
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
<span className="sm:hidden">Ф</span>
<NotificationBadge count={ourSupplyOrdersCount} />
</TabsTrigger>
<TabsTrigger
value="consumables"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
>
<Wrench className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
<span className="hidden md:inline">Расходники селлеров</span>
<span className="md:hidden hidden sm:inline">Селлеры</span>
<span className="sm:hidden">С</span>
<NotificationBadge count={sellerSupplyOrdersCount} />
</TabsTrigger>
<TabsTrigger
value="returns"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs"
>
<RotateCcw className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
<span className="sm:hidden">В</span>
</TabsTrigger>
</TabsList>
<TabsContent value="goods" className="flex-1 overflow-hidden">
<FulfillmentGoodsTab />
</TabsContent>
<TabsContent
value="detailed-supplies"
className="flex-1 overflow-hidden"
<div className="space-y-3">
{/* УРОВЕНЬ 2: Подтабы (средний размер, отступ показывает иерархию) */}
<div className="ml-4">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<div className="h-full p-2 xl:p-4 overflow-y-auto">
<FulfillmentDetailedSuppliesTab />
</div>
</TabsContent>
<TabsList className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1 mb-3">
<TabsTrigger
value="goods"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
>
<Package className="h-3 w-3" />
<span className="hidden sm:inline">Товар</span>
<span className="sm:hidden">Т</span>
</TabsTrigger>
<TabsTrigger
value="detailed-supplies"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
>
<Building2 className="h-3 w-3" />
<span className="hidden md:inline">Расходники фулфилмента</span>
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
<span className="sm:hidden">Ф</span>
<NotificationBadge count={ourSupplyOrdersCount} />
</TabsTrigger>
<TabsTrigger
value="consumables"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
>
<Wrench className="h-3 w-3" />
<span className="hidden md:inline">Расходники селлеров</span>
<span className="md:hidden hidden sm:inline">Селлеры</span>
<span className="sm:hidden">С</span>
<NotificationBadge count={sellerSupplyOrdersCount} />
</TabsTrigger>
<TabsTrigger
value="returns"
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
>
<RotateCcw className="h-3 w-3" />
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
<span className="sm:hidden">В</span>
</TabsTrigger>
</TabsList>
<TabsContent value="consumables" className="flex-1 overflow-hidden">
<div className="h-full p-2 xl:p-4 overflow-y-auto">
<FulfillmentConsumablesOrdersTab />
</div>
</TabsContent>
<TabsContent value="goods" className="space-y-0">
<FulfillmentGoodsTab />
</TabsContent>
<TabsContent value="returns" className="flex-1 overflow-hidden">
<PvzReturnsTab />
</TabsContent>
</Tabs>
<TabsContent value="detailed-supplies" className="space-y-0">
<div className="p-4">
<FulfillmentDetailedSuppliesTab />
</div>
</TabsContent>
<TabsContent value="consumables" className="space-y-0">
<div className="p-4">
<FulfillmentConsumablesOrdersTab />
</div>
</TabsContent>
<TabsContent value="returns" className="space-y-0">
<PvzReturnsTab />
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useAuth } from "@/hooks/useAuth";
@ -15,7 +20,8 @@ import {
GET_MY_COUNTERPARTIES,
GET_SUPPLY_ORDERS,
GET_WAREHOUSE_PRODUCTS,
GET_MY_SUPPLIES, // Расходники селлеров
GET_MY_SUPPLIES, // Расходники селлеров (старые данные заказов)
GET_SELLER_SUPPLIES_ON_WAREHOUSE, // Расходники селлеров на складе (новый API)
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
} from "@/graphql/queries";
@ -55,6 +61,7 @@ interface ProductVariant {
defectsQuantity: number;
sellerSuppliesPlace?: string;
sellerSuppliesQuantity: number;
sellerSuppliesOwners?: string[]; // Владельцы расходников
pvzReturnsPlace?: string;
pvzReturnsQuantity: number;
}
@ -72,6 +79,7 @@ interface ProductItem {
defectsQuantity: number;
sellerSuppliesPlace?: string;
sellerSuppliesQuantity: number;
sellerSuppliesOwners?: string[]; // Владельцы расходников
pvzReturnsPlace?: string;
pvzReturnsQuantity: number;
// Третий уровень - варианты товара
@ -200,13 +208,13 @@ export function FulfillmentWarehouseDashboard() {
fetchPolicy: "cache-and-network",
});
// Загружаем расходники селлеров
// Загружаем расходники селлеров на складе фулфилмента
const {
data: suppliesData,
loading: suppliesLoading,
error: suppliesError,
refetch: refetchSupplies,
} = useQuery(GET_MY_SUPPLIES, {
data: sellerSuppliesData,
loading: sellerSuppliesLoading,
error: sellerSuppliesError,
refetch: refetchSellerSupplies,
} = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
fetchPolicy: "cache-and-network",
});
@ -246,8 +254,10 @@ export function FulfillmentWarehouseDashboard() {
goods: warehouseStatsData.fulfillmentWarehouseStats.goods,
defects: warehouseStatsData.fulfillmentWarehouseStats.defects,
pvzReturns: warehouseStatsData.fulfillmentWarehouseStats.pvzReturns,
fulfillmentSupplies: warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
sellerSupplies: warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
fulfillmentSupplies:
warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
sellerSupplies:
warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
});
}
@ -258,7 +268,7 @@ export function FulfillmentWarehouseDashboard() {
);
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
const allProducts = productsData?.warehouseProducts || [];
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
const sellerSupplies = sellerSuppliesData?.sellerSuppliesOnWarehouse || []; // Расходники селлеров на складе
const myFulfillmentSupplies =
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
@ -276,8 +286,8 @@ export function FulfillmentWarehouseDashboard() {
deliveredOrders: supplyOrders.filter((o) => o.status === "DELIVERED")
.length,
productsCount: allProducts.length,
suppliesCount: mySupplies.length, // Добавляем логирование расходников
supplies: mySupplies.map((s: any) => ({
suppliesCount: sellerSupplies.length, // Добавляем логирование расходников
supplies: sellerSupplies.map((s: any) => ({
id: s.id,
name: s.name,
currentStock: s.currentStock,
@ -293,7 +303,7 @@ export function FulfillmentWarehouseDashboard() {
})),
// Добавляем анализ соответствия товаров и расходников
productSupplyMatching: allProducts.map((product: any) => {
const matchingSupply = mySupplies.find((supply: any) => {
const matchingSupply = sellerSupplies.find((supply: any) => {
return (
supply.name.toLowerCase() === product.name.toLowerCase() ||
supply.name
@ -311,11 +321,11 @@ export function FulfillmentWarehouseDashboard() {
counterpartiesLoading,
ordersLoading,
productsLoading,
suppliesLoading, // Добавляем статус загрузки расходников
sellerSuppliesLoading, // Добавляем статус загрузки расходников селлеров
counterpartiesError: counterpartiesError?.message,
ordersError: ordersError?.message,
productsError: productsError?.message,
suppliesError: suppliesError?.message, // Добавляем ошибки загрузки расходников
sellerSuppliesError: sellerSuppliesError?.message, // Добавляем ошибки загрузки расходников селлеров
});
// Расчет поступлений расходников за сутки (выносим отдельно для использования в storeData)
@ -408,7 +418,7 @@ export function FulfillmentWarehouseDashboard() {
console.log("📊 Статистика расходников селлера:", {
suppliesReceivedToday,
suppliesUsedToday,
totalSellerSupplies: mySupplies.reduce(
totalSellerSupplies: sellerSupplies.reduce(
(sum: number, supply: any) => sum + (supply.currentStock || 0),
0
),
@ -418,7 +428,10 @@ export function FulfillmentWarehouseDashboard() {
// Получаем статистику склада из GraphQL (с реальными изменениями за сутки)
const warehouseStats: WarehouseStats = useMemo(() => {
// Если данные еще загружаются, возвращаем нули
if (warehouseStatsLoading || !warehouseStatsData?.fulfillmentWarehouseStats) {
if (
warehouseStatsLoading ||
!warehouseStatsData?.fulfillmentWarehouseStats
) {
return {
products: { current: 0, change: 0 },
goods: { current: 0, change: 0 },
@ -511,26 +524,64 @@ export function FulfillmentWarehouseDashboard() {
}
});
// Группируем расходники по названию
const groupedSupplies = new Map<string, number>();
mySupplies.forEach((supply: any) => {
// ИСПРАВЛЕНО: Группируем расходники по СЕЛЛЕРУ-ВЛАДЕЛЬЦУ, а не по названию
const suppliesByOwner = new Map<
string,
Map<string, { quantity: number; ownerName: string }>
>();
sellerSupplies.forEach((supply: any) => {
const ownerId = supply.sellerOwner?.id;
const ownerName =
supply.sellerOwner?.name ||
supply.sellerOwner?.fullName ||
"Неизвестный селлер";
const supplyName = supply.name;
const currentStock = supply.currentStock || 0;
const supplyType = supply.type;
if (groupedSupplies.has(supplyName)) {
groupedSupplies.set(
supplyName,
groupedSupplies.get(supplyName)! + currentStock
// ИСПРАВЛЕНО: Строгая проверка согласно правилам
if (!ownerId || supplyType !== "SELLER_CONSUMABLES") {
console.warn(
"⚠️ ОТФИЛЬТРОВАН расходник в компоненте (нарушение правил):",
{
id: supply.id,
name: supplyName,
type: supplyType,
ownerId,
ownerName,
reason: !ownerId
? "нет sellerOwner.id"
: "тип не SELLER_CONSUMABLES",
}
);
return; // Пропускаем согласно ПРАВИЛУ 6 из секции 11.6
}
// Инициализируем группу для селлера, если её нет
if (!suppliesByOwner.has(ownerId)) {
suppliesByOwner.set(ownerId, new Map());
}
const ownerSupplies = suppliesByOwner.get(ownerId)!;
if (ownerSupplies.has(supplyName)) {
// Суммируем количество, если расходник уже есть у этого селлера
const existing = ownerSupplies.get(supplyName)!;
existing.quantity += currentStock;
} else {
groupedSupplies.set(supplyName, currentStock);
// Добавляем новый расходник для этого селлера
ownerSupplies.set(supplyName, {
quantity: currentStock,
ownerName: ownerName,
});
}
});
// Логирование группировки
console.log("📊 Группировка товаров и расходников:", {
groupedProductsCount: groupedProducts.size,
groupedSuppliesCount: groupedSupplies.size,
suppliesByOwnerCount: suppliesByOwner.size,
groupedProducts: Array.from(groupedProducts.entries()).map(
([name, data]) => ({
name,
@ -539,10 +590,20 @@ export function FulfillmentWarehouseDashboard() {
uniqueSuppliers: [...new Set(data.suppliers)],
})
),
groupedSupplies: Array.from(groupedSupplies.entries()).map(
([name, quantity]) => ({
name,
totalQuantity: quantity,
suppliesByOwner: Array.from(suppliesByOwner.entries()).map(
([ownerId, ownerSupplies]) => ({
ownerId,
suppliesCount: ownerSupplies.size,
totalQuantity: Array.from(ownerSupplies.values()).reduce(
(sum, s) => sum + s.quantity,
0
),
ownerName:
Array.from(ownerSupplies.values())[0]?.ownerName || "Unknown",
supplies: Array.from(ownerSupplies.entries()).map(([name, data]) => ({
name,
quantity: data.quantity,
})),
})
),
});
@ -567,37 +628,56 @@ export function FulfillmentWarehouseDashboard() {
const productData = groupedProducts.get(productName)!;
const itemProducts = productData.totalQuantity;
// Ищем соответствующий расходник по названию
const matchingSupplyQuantity = groupedSupplies.get(productName) || 0;
// ИСПРАВЛЕНО: Ищем расходники конкретного селлера-владельца
let itemSuppliesQuantity = 0;
let suppliesOwners: string[] = [];
// Если нет точного совпадения, ищем частичное совпадение
let itemSuppliesQuantity = matchingSupplyQuantity;
if (itemSuppliesQuantity === 0) {
for (const [supplyName, quantity] of groupedSupplies.entries()) {
if (
supplyName.toLowerCase().includes(productName.toLowerCase()) ||
productName.toLowerCase().includes(supplyName.toLowerCase())
) {
itemSuppliesQuantity = quantity;
break;
// Получаем реального селлера для этого виртуального партнера
const realSeller = sellerPartners[index];
if (realSeller?.id && suppliesByOwner.has(realSeller.id)) {
const sellerSupplies = suppliesByOwner.get(realSeller.id)!;
// Ищем расходники этого селлера по названию товара
const matchingSupply = sellerSupplies.get(productName);
if (matchingSupply) {
itemSuppliesQuantity = matchingSupply.quantity;
suppliesOwners = [matchingSupply.ownerName];
} else {
// Если нет точного совпадения, ищем частичное среди расходников ЭТОГО селлера
for (const [supplyName, supplyData] of sellerSupplies.entries()) {
if (
supplyName
.toLowerCase()
.includes(productName.toLowerCase()) ||
productName.toLowerCase().includes(supplyName.toLowerCase())
) {
itemSuppliesQuantity = supplyData.quantity;
suppliesOwners = [supplyData.ownerName];
break;
}
}
}
}
// Fallback к процентному соотношению
if (itemSuppliesQuantity === 0) {
itemSuppliesQuantity = Math.floor(itemProducts * 0.1);
}
// Если у этого селлера нет расходников для данного товара - оставляем 0
// НЕ используем fallback, так как должны показывать только реальные данные
console.log(`📦 Товар "${productName}":`, {
totalQuantity: itemProducts,
suppliersCount: productData.suppliers.length,
uniqueSuppliers: [...new Set(productData.suppliers)],
matchingSupplyQuantity: matchingSupplyQuantity,
finalSuppliesQuantity: itemSuppliesQuantity,
usedFallback:
matchingSupplyQuantity === 0 && itemSuppliesQuantity > 0,
});
console.log(
`📦 Товар "${productName}" (партнер: ${
realSeller?.name || "Unknown"
}):`,
{
totalQuantity: itemProducts,
suppliersCount: productData.suppliers.length,
uniqueSuppliers: [...new Set(productData.suppliers)],
sellerSuppliesQuantity: itemSuppliesQuantity,
suppliesOwners: suppliesOwners,
sellerId: realSeller?.id,
hasSellerSupplies: itemSuppliesQuantity > 0,
}
);
return {
id: `grouped-${productName}-${itemIndex}`, // Уникальный ID для группированного товара
@ -615,6 +695,7 @@ export function FulfillmentWarehouseDashboard() {
defectsQuantity: 0, // Нет реальных данных о браке
sellerSuppliesPlace: `D${index + 1}-${itemIndex + 1}`,
sellerSuppliesQuantity: itemSuppliesQuantity, // Суммированное количество расходников (реальные данные)
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}`,
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах с ПВЗ
// Создаем варианты товара
@ -634,6 +715,7 @@ export function FulfillmentWarehouseDashboard() {
sellerSuppliesQuantity: Math.floor(
itemSuppliesQuantity * 0.4
), // Часть от расходников
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-1`,
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
},
@ -650,6 +732,7 @@ export function FulfillmentWarehouseDashboard() {
sellerSuppliesQuantity: Math.floor(
itemSuppliesQuantity * 0.4
), // Часть от расходников
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-2`,
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
},
@ -666,6 +749,7 @@ export function FulfillmentWarehouseDashboard() {
sellerSuppliesQuantity: Math.floor(
itemSuppliesQuantity * 0.2
), // Оставшаяся часть расходников
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-3`,
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
},
@ -738,7 +822,7 @@ export function FulfillmentWarehouseDashboard() {
totalSellerSupplies > 0
? Math.floor(
(totalSellerSupplies /
(mySupplies.reduce(
(sellerSupplies.reduce(
(sum: number, supply: any) =>
sum + (supply.currentStock || 0),
0
@ -774,7 +858,7 @@ export function FulfillmentWarehouseDashboard() {
items,
};
});
}, [sellerPartners, allProducts, mySupplies, suppliesReceivedToday]);
}, [sellerPartners, allProducts, sellerSupplies, suppliesReceivedToday]);
// Функции для аватаров магазинов
const getInitials = (name: string): string => {
@ -1007,9 +1091,14 @@ export function FulfillmentWarehouseDashboard() {
onClick?: () => void;
}) => {
// Используем percentChange из GraphQL, если доступно, иначе вычисляем локально
const displayPercentChange = percentChange !== undefined && percentChange !== null && !isNaN(percentChange)
? percentChange
: (current > 0 ? (change / current) * 100 : 0);
const displayPercentChange =
percentChange !== undefined &&
percentChange !== null &&
!isNaN(percentChange)
? percentChange
: current > 0
? (change / current) * 100
: 0;
return (
<div
@ -1125,7 +1214,7 @@ export function FulfillmentWarehouseDashboard() {
counterpartiesLoading ||
ordersLoading ||
productsLoading ||
suppliesLoading
sellerSuppliesLoading
) {
return (
<div className="h-screen flex overflow-hidden">
@ -1210,7 +1299,7 @@ export function FulfillmentWarehouseDashboard() {
counterpartiesLoading ||
ordersLoading ||
productsLoading ||
suppliesLoading
sellerSuppliesLoading
}
>
<RotateCcw className="h-3 w-3 mr-1" />
@ -1224,7 +1313,10 @@ export function FulfillmentWarehouseDashboard() {
icon={Box}
current={warehouseStats.products.current}
change={warehouseStats.products.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.products?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats?.products
?.percentChange
}
description="Готовые к отправке"
/>
<StatCard
@ -1232,7 +1324,10 @@ export function FulfillmentWarehouseDashboard() {
icon={Package}
current={warehouseStats.goods.current}
change={warehouseStats.goods.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.goods?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats?.goods
?.percentChange
}
description="В обработке"
/>
<StatCard
@ -1240,7 +1335,10 @@ export function FulfillmentWarehouseDashboard() {
icon={AlertTriangle}
current={warehouseStats.defects.current}
change={warehouseStats.defects.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.defects?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats?.defects
?.percentChange
}
description="Требует утилизации"
/>
<StatCard
@ -1248,7 +1346,10 @@ export function FulfillmentWarehouseDashboard() {
icon={RotateCcw}
current={warehouseStats.pvzReturns.current}
change={warehouseStats.pvzReturns.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns
?.percentChange
}
description="К обработке"
/>
<StatCard
@ -1256,7 +1357,10 @@ export function FulfillmentWarehouseDashboard() {
icon={Wrench}
current={warehouseStats.fulfillmentSupplies.current}
change={warehouseStats.fulfillmentSupplies.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.fulfillmentSupplies?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats
?.fulfillmentSupplies?.percentChange
}
description="Расходники, этикетки"
onClick={() => router.push("/fulfillment-warehouse/supplies")}
/>
@ -1265,7 +1369,10 @@ export function FulfillmentWarehouseDashboard() {
icon={Users}
current={warehouseStats.sellerSupplies.current}
change={warehouseStats.sellerSupplies.change}
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies?.percentChange}
percentChange={
warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies
?.percentChange
}
description="Материалы клиентов"
/>
</div>
@ -1935,11 +2042,43 @@ export function FulfillmentWarehouseDashboard() {
{/* Расходники селлера */}
<div className="grid grid-cols-2 gap-0">
<div className="px-1 py-2 text-center text-xs text-white font-medium">
{formatNumber(
item.sellerSuppliesQuantity
)}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="px-1 py-2 text-center text-xs text-white font-medium cursor-help hover:bg-white/10 rounded">
{formatNumber(
item.sellerSuppliesQuantity
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-64 glass-card">
<div className="text-xs">
<div className="font-medium mb-2 text-white">
Расходники селлеров:
</div>
{item.sellerSuppliesOwners &&
item.sellerSuppliesOwners.length >
0 ? (
<div className="space-y-1">
{item.sellerSuppliesOwners.map(
(owner, i) => (
<div
key={i}
className="text-white/80 flex items-center"
>
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
{owner}
</div>
)
)}
</div>
) : (
<div className="text-white/60">
Нет данных о владельцах
</div>
)}
</div>
</PopoverContent>
</Popover>
<div className="px-1 py-2 text-center text-xs text-white/60">
{item.sellerSuppliesPlace || "-"}
</div>
@ -2065,11 +2204,45 @@ export function FulfillmentWarehouseDashboard() {
{/* Расходники селлера */}
<div className="grid grid-cols-2 gap-0">
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium">
{formatNumber(
variant.sellerSuppliesQuantity
)}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium cursor-help hover:bg-white/10 rounded">
{formatNumber(
variant.sellerSuppliesQuantity
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-64 glass-card">
<div className="text-xs">
<div className="font-medium mb-2 text-white">
Расходники селлеров:
</div>
{variant.sellerSuppliesOwners &&
variant
.sellerSuppliesOwners
.length > 0 ? (
<div className="space-y-1">
{variant.sellerSuppliesOwners.map(
(owner, i) => (
<div
key={i}
className="text-white/80 flex items-center"
>
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
{owner}
</div>
)
)}
</div>
) : (
<div className="text-white/60">
Нет данных о
владельцах
</div>
)}
</div>
</PopoverContent>
</Popover>
<div className="px-1 py-1.5 text-center text-[10px] text-white/60">
{variant.sellerSuppliesPlace ||
"-"}

View File

@ -11,9 +11,9 @@ import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useAuth } from "@/hooks/useAuth";
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
import {
LOGISTICS_CONFIRM_ORDER,
LOGISTICS_REJECT_ORDER
import {
LOGISTICS_CONFIRM_ORDER,
LOGISTICS_REJECT_ORDER,
} from "@/graphql/mutations";
import { toast } from "sonner";
import {
@ -37,7 +37,15 @@ interface SupplyOrder {
organizationId: string;
partnerId: string;
deliveryDate: string;
status: "PENDING" | "SUPPLIER_APPROVED" | "LOGISTICS_CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED";
status:
| "PENDING"
| "SUPPLIER_APPROVED"
| "CONFIRMED"
| "LOGISTICS_CONFIRMED"
| "SHIPPED"
| "IN_TRANSIT"
| "DELIVERED"
| "CANCELLED";
totalAmount: number;
totalItems: number;
createdAt: string;
@ -89,7 +97,11 @@ export function LogisticsOrdersDashboard() {
fetchPolicy: "cache-and-network",
});
console.log(`DEBUG ЛОГИСТИКА: loading=${loading}, error=${error?.message}, totalOrders=${data?.supplyOrders?.length || 0}`);
console.log(
`DEBUG ЛОГИСТИКА: loading=${loading}, error=${
error?.message
}, totalOrders=${data?.supplyOrders?.length || 0}`
);
// Мутации для действий логистики
const [logisticsConfirmOrder] = useMutation(LOGISTICS_CONFIRM_ORDER, {
@ -137,8 +149,15 @@ export function LogisticsOrdersDashboard() {
// Фильтруем заказы где текущая организация является логистическим партнером
const logisticsOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
(order: SupplyOrder) => {
const isLogisticsPartner = order.logisticsPartner?.id === user?.organization?.id;
console.log(`DEBUG ЛОГИСТИКА: Заказ ${order.id.slice(-8)} - статус: ${order.status}, logisticsPartnerId: ${order.logisticsPartner?.id}, currentOrgId: ${user?.organization?.id}, isLogisticsPartner: ${isLogisticsPartner}`);
const isLogisticsPartner =
order.logisticsPartner?.id === user?.organization?.id;
console.log(
`DEBUG ЛОГИСТИКА: Заказ ${order.id.slice(-8)} - статус: ${
order.status
}, logisticsPartnerId: ${order.logisticsPartner?.id}, currentOrgId: ${
user?.organization?.id
}, isLogisticsPartner: ${isLogisticsPartner}`
);
return isLogisticsPartner;
}
);
@ -155,6 +174,11 @@ export function LogisticsOrdersDashboard() {
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
icon: AlertTriangle,
},
CONFIRMED: {
label: "Требует подтверждения",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
icon: AlertTriangle,
},
LOGISTICS_CONFIRMED: {
label: "Подтверждено",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
@ -187,7 +211,7 @@ export function LogisticsOrdersDashboard() {
icon: Truck,
},
};
const config = statusMap[status as keyof typeof statusMap];
if (!config) {
console.warn(`Unknown status: ${status}`);
@ -199,7 +223,7 @@ export function LogisticsOrdersDashboard() {
</Badge>
);
}
const { label, color, icon: Icon } = config;
return (
<Badge className={`${color} border flex items-center gap-1 text-xs`}>
@ -247,7 +271,9 @@ export function LogisticsOrdersDashboard() {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<main
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
>
<div className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="text-white">Загрузка заказов...</div>
</div>
@ -260,9 +286,13 @@ export function LogisticsOrdersDashboard() {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<main
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
>
<div className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="text-red-300">Ошибка загрузки заказов: {error.message}</div>
<div className="text-red-300">
Ошибка загрузки заказов: {error.message}
</div>
</div>
</main>
</div>
@ -272,7 +302,9 @@ export function LogisticsOrdersDashboard() {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<main
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
>
<div className="flex-1 overflow-y-auto space-y-6">
{/* Заголовок */}
<div className="flex items-center justify-between">
@ -296,7 +328,13 @@ export function LogisticsOrdersDashboard() {
<div>
<p className="text-white/60 text-sm">Требуют подтверждения</p>
<p className="text-xl font-bold text-white">
{logisticsOrders.filter(order => order.status === "SUPPLIER_APPROVED").length}
{
logisticsOrders.filter(
(order) =>
order.status === "SUPPLIER_APPROVED" ||
order.status === "CONFIRMED"
).length
}
</p>
</div>
</div>
@ -310,7 +348,11 @@ export function LogisticsOrdersDashboard() {
<div>
<p className="text-white/60 text-sm">Подтверждено</p>
<p className="text-xl font-bold text-white">
{logisticsOrders.filter(order => order.status === "LOGISTICS_CONFIRMED").length}
{
logisticsOrders.filter(
(order) => order.status === "LOGISTICS_CONFIRMED"
).length
}
</p>
</div>
</div>
@ -324,7 +366,11 @@ export function LogisticsOrdersDashboard() {
<div>
<p className="text-white/60 text-sm">В пути</p>
<p className="text-xl font-bold text-white">
{logisticsOrders.filter(order => order.status === "SHIPPED").length}
{
logisticsOrders.filter(
(order) => order.status === "SHIPPED"
).length
}
</p>
</div>
</div>
@ -338,7 +384,11 @@ export function LogisticsOrdersDashboard() {
<div>
<p className="text-white/60 text-sm">Доставлено</p>
<p className="text-xl font-bold text-white">
{logisticsOrders.filter(order => order.status === "DELIVERED").length}
{
logisticsOrders.filter(
(order) => order.status === "DELIVERED"
).length
}
</p>
</div>
</div>
@ -355,7 +405,8 @@ export function LogisticsOrdersDashboard() {
Нет логистических заказов
</h3>
<p className="text-white/60">
Заказы поставок, требующие логистического сопровождения, будут отображаться здесь
Заказы поставок, требующие логистического сопровождения,
будут отображаться здесь
</p>
</div>
</Card>
@ -384,19 +435,29 @@ export function LogisticsOrdersDashboard() {
<div className="flex items-center space-x-2">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-blue-500 text-white text-sm">
{getInitials(order.partner.name || order.partner.fullName || "П")}
{getInitials(
order.partner.name ||
order.partner.fullName ||
"П"
)}
</AvatarFallback>
</Avatar>
<span className="text-white/60 text-sm"></span>
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-green-500 text-white text-sm">
{getInitials(order.organization.name || order.organization.fullName || "ФФ")}
{getInitials(
order.organization.name ||
order.organization.fullName ||
"ФФ"
)}
</AvatarFallback>
</Avatar>
</div>
<div className="min-w-0">
<h3 className="text-white font-medium text-sm truncate">
{order.partner.name || order.partner.fullName} {order.organization.name || order.organization.fullName}
{order.partner.name || order.partner.fullName} {" "}
{order.organization.name ||
order.organization.fullName}
</h3>
<p className="text-white/60 text-xs">
Поставщик Фулфилмент
@ -426,7 +487,8 @@ export function LogisticsOrdersDashboard() {
{getStatusBadge(order.status)}
{/* Кнопки действий для логистики */}
{order.status === "SUPPLIER_APPROVED" && (
{(order.status === "SUPPLIER_APPROVED" ||
order.status === "CONFIRMED") && (
<div className="flex items-center space-x-2">
<Button
size="sm"
@ -459,7 +521,7 @@ export function LogisticsOrdersDashboard() {
{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">
@ -490,7 +552,8 @@ export function LogisticsOrdersDashboard() {
</h4>
<div className="bg-white/5 rounded p-3">
<p className="text-white">
{order.organization.name || order.organization.fullName}
{order.organization.name ||
order.organization.fullName}
</p>
</div>
</div>
@ -589,4 +652,4 @@ export function LogisticsOrdersDashboard() {
</main>
</div>
);
}
}

View 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>
</>
);
}

View 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>
);
}

View File

@ -1,591 +1,35 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation } from "@apollo/client";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useAuth } from "@/hooks/useAuth";
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
import {
SUPPLIER_APPROVE_ORDER,
SUPPLIER_REJECT_ORDER,
SUPPLIER_SHIP_ORDER
} from "@/graphql/mutations";
import { toast } from "sonner";
import {
Calendar,
Package,
Truck,
User,
CheckCircle,
Clock,
XCircle,
MapPin,
Phone,
Mail,
Building,
Hash,
AlertTriangle,
} from "lucide-react";
interface SupplyOrder {
id: string;
organizationId: string;
partnerId: string;
deliveryDate: string;
status: "PENDING" | "SUPPLIER_APPROVED" | "LOGISTICS_CONFIRMED" | "SHIPPED" | "DELIVERED" | "CANCELLED";
totalAmount: number;
totalItems: number;
createdAt: string;
organization: {
id: string;
name?: string;
fullName?: string;
type: string;
};
fulfillmentCenter?: {
id: string;
name?: string;
fullName?: string;
type: string;
};
logisticsPartner?: {
id: string;
name?: string;
fullName?: string;
type: string;
};
items: Array<{
id: string;
quantity: number;
price: number;
totalPrice: number;
product: {
id: string;
name: string;
article: string;
description?: string;
category?: {
id: string;
name: string;
};
};
}>;
}
import { SupplierOrdersTabs } from "./supplier-orders-tabs";
import { Package, AlertTriangle } from "lucide-react";
export function SupplierOrdersDashboard() {
const { getSidebarMargin } = useSidebar();
const { user } = useAuth();
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
const [rejectReason, setRejectReason] = useState<string>("");
const [showRejectModal, setShowRejectModal] = useState<string | null>(null);
// Загружаем заказы поставок
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
fetchPolicy: "cache-and-network",
});
// Мутации для действий поставщика
const [supplierApproveOrder] = useMutation(SUPPLIER_APPROVE_ORDER, {
refetchQueries: [
{ query: GET_SUPPLY_ORDERS },
"GetMyProducts", // Обновляем товары поставщика
"GetWarehouseProducts", // Обновляем склад фулфилмента (если нужно)
],
awaitRefetchQueries: true,
onCompleted: (data) => {
if (data.supplierApproveOrder.success) {
toast.success(data.supplierApproveOrder.message);
} else {
toast.error(data.supplierApproveOrder.message);
}
},
onError: (error) => {
console.error("Error approving order:", error);
toast.error("Ошибка при одобрении заказа");
},
});
const [supplierRejectOrder] = useMutation(SUPPLIER_REJECT_ORDER, {
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
onCompleted: (data) => {
if (data.supplierRejectOrder.success) {
toast.success(data.supplierRejectOrder.message);
} else {
toast.error(data.supplierRejectOrder.message);
}
setShowRejectModal(null);
setRejectReason("");
},
onError: (error) => {
console.error("Error rejecting order:", error);
toast.error("Ошибка при отклонении заказа");
},
});
const [supplierShipOrder] = useMutation(SUPPLIER_SHIP_ORDER, {
refetchQueries: [
{ query: GET_SUPPLY_ORDERS },
"GetMyProducts", // Обновляем товары поставщика для актуальных остатков
],
onCompleted: (data) => {
if (data.supplierShipOrder.success) {
toast.success(data.supplierShipOrder.message);
} else {
toast.error(data.supplierShipOrder.message);
}
},
onError: (error) => {
console.error("Error shipping order:", error);
toast.error("Ошибка при отправке заказа");
},
});
const toggleOrderExpansion = (orderId: string) => {
const newExpanded = new Set(expandedOrders);
if (newExpanded.has(orderId)) {
newExpanded.delete(orderId);
} else {
newExpanded.add(orderId);
}
setExpandedOrders(newExpanded);
};
// Фильтруем заказы где текущая организация является поставщиком
// В GraphQL partnerId - это ID поставщика, а organizationId - это ID создателя заказа
const supplierOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
(order: SupplyOrder) => {
// Нужно найти поле partner или использовать partnerId
// Проверяем через partnerId из схемы
const isSupplier = order.partnerId === user?.organization?.id;
return isSupplier;
}
);
const getStatusBadge = (status: SupplyOrder["status"]) => {
const statusMap = {
PENDING: {
label: "Ожидает одобрения",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
icon: Clock,
},
SUPPLIER_APPROVED: {
label: "Ожидает подтверждения логистики",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
icon: Clock,
},
LOGISTICS_CONFIRMED: {
label: "Готов к отправке",
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
icon: CheckCircle,
},
SHIPPED: {
label: "Отправлено",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
icon: Truck,
},
DELIVERED: {
label: "Доставлено",
color: "bg-green-500/20 text-green-300 border-green-500/30",
icon: Package,
},
CANCELLED: {
label: "Отменено",
color: "bg-red-500/20 text-red-300 border-red-500/30",
icon: XCircle,
},
};
const { label, color, icon: Icon } = statusMap[status];
return (
<Badge className={`${color} border flex items-center gap-1 text-xs`}>
<Icon className="h-3 w-3" />
{label}
</Badge>
);
};
const handleApproveOrder = async (orderId: string) => {
await supplierApproveOrder({ variables: { id: orderId } });
};
const handleRejectOrder = async (orderId: string) => {
await supplierRejectOrder({
variables: { id: orderId, reason: rejectReason || undefined },
});
};
const handleShipOrder = async (orderId: string) => {
await supplierShipOrder({ variables: { id: orderId } });
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
}).format(amount);
};
const getInitials = (name: string): string => {
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
};
if (loading) {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<div className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="text-white">Загрузка заказов...</div>
</div>
</main>
</div>
);
}
if (error) {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<div className="flex-1 overflow-y-auto flex items-center justify-center">
<div className="text-red-300">Ошибка загрузки заказов: {error.message}</div>
</div>
</main>
</div>
);
}
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}>
<main
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
>
<div className="flex-1 overflow-y-auto space-y-6">
{/* Заголовок */}
{/* Заголовок страницы */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white mb-2">
Заказы поставок
</h1>
<h1 className="text-2xl font-bold text-white mb-2">Заявки</h1>
<p className="text-white/60">
Управление входящими заказами от фулфилмент-центров
Управление входящими заявками от заказчиков согласно правилам
системы
</p>
</div>
</div>
{/* Статистика */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-yellow-500/20 rounded">
<Clock className="h-5 w-5 text-yellow-400" />
</div>
<div>
<p className="text-white/60 text-sm">Ожидают одобрения</p>
<p className="text-xl font-bold text-white">
{supplierOrders.filter(order => order.status === "PENDING").length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-cyan-500/20 rounded">
<CheckCircle className="h-5 w-5 text-cyan-400" />
</div>
<div>
<p className="text-white/60 text-sm">Готово к отправке</p>
<p className="text-xl font-bold text-white">
{supplierOrders.filter(order => order.status === "LOGISTICS_CONFIRMED").length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-orange-500/20 rounded">
<Truck className="h-5 w-5 text-orange-400" />
</div>
<div>
<p className="text-white/60 text-sm">В пути</p>
<p className="text-xl font-bold text-white">
{supplierOrders.filter(order => order.status === "SHIPPED").length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-green-500/20 rounded">
<Package className="h-5 w-5 text-green-400" />
</div>
<div>
<p className="text-white/60 text-sm">Доставлено</p>
<p className="text-xl font-bold text-white">
{supplierOrders.filter(order => order.status === "DELIVERED").length}
</p>
</div>
</div>
</Card>
</div>
{/* Список заказов */}
<div className="space-y-4">
{supplierOrders.length === 0 ? (
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
<div className="text-center">
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
Нет заказов поставок
</h3>
<p className="text-white/60">
Входящие заказы от фулфилмент-центров будут отображаться здесь
</p>
</div>
</Card>
) : (
supplierOrders.map((order) => (
<Card
key={order.id}
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
onClick={() => toggleOrderExpansion(order.id)}
>
{/* Основная информация о заказе */}
<div className="p-4">
<div className="flex items-center justify-between">
{/* Левая часть */}
<div className="flex items-center space-x-4 flex-1 min-w-0">
{/* Номер заказа */}
<div className="flex items-center space-x-2">
<Hash className="h-4 w-4 text-white/60" />
<span className="text-white font-semibold">
{order.id.slice(-8)}
</span>
</div>
{/* Заказчик */}
<div className="flex items-center space-x-3 min-w-0">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-blue-500 text-white text-sm">
{getInitials(order.organization.name || order.organization.fullName || "ФФ")}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h3 className="text-white font-medium text-sm truncate">
{order.organization.name || order.organization.fullName}
</h3>
<p className="text-white/60 text-xs">
{order.organization.type === "FULFILLMENT" ? "Фулфилмент" : "Организация"}
</p>
</div>
</div>
{/* Краткая информация */}
<div className="hidden lg:flex items-center space-x-4">
<div className="flex items-center space-x-1">
<Calendar className="h-4 w-4 text-blue-400" />
<span className="text-white text-sm">
{formatDate(order.deliveryDate)}
</span>
</div>
<div className="flex items-center space-x-1">
<Package className="h-4 w-4 text-green-400" />
<span className="text-white text-sm">
{order.totalItems} шт.
</span>
</div>
</div>
</div>
{/* Правая часть - статус и действия */}
<div className="flex items-center space-x-3 flex-shrink-0">
{getStatusBadge(order.status)}
{/* Кнопки действий для поставщика */}
{order.status === "PENDING" && (
<div className="flex items-center space-x-2">
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleApproveOrder(order.id);
}}
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-3 py-1 h-7"
>
<CheckCircle className="h-3 w-3 mr-1" />
Одобрить
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowRejectModal(order.id);
}}
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30 text-xs px-3 py-1 h-7"
>
<XCircle className="h-3 w-3 mr-1" />
Отклонить
</Button>
</div>
)}
{order.status === "LOGISTICS_CONFIRMED" && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleShipOrder(order.id);
}}
className="bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border border-orange-500/30 text-xs px-3 py-1 h-7"
>
<Truck className="h-3 w-3 mr-1" />
Отправить
</Button>
)}
</div>
</div>
{/* Развернутые детали */}
{expandedOrders.has(order.id) && (
<>
<Separator className="my-4 bg-white/10" />
{/* Сумма заказа */}
<div className="mb-4 p-3 bg-white/5 rounded">
<div className="flex items-center justify-between">
<span className="text-white/60">Общая сумма:</span>
<span className="text-white font-semibold text-lg">
{formatCurrency(order.totalAmount)}
</span>
</div>
</div>
{/* Информация о логистике */}
{order.logisticsPartner && (
<div className="mb-4">
<h4 className="text-white font-semibold mb-2 flex items-center text-sm">
<Truck className="h-4 w-4 mr-2 text-purple-400" />
Логистическая компания
</h4>
<div className="bg-white/5 rounded p-3">
<p className="text-white">
{order.logisticsPartner.name || order.logisticsPartner.fullName}
</p>
</div>
</div>
)}
{/* Список товаров */}
<div>
<h4 className="text-white font-semibold mb-3 flex items-center text-sm">
<Package className="h-4 w-4 mr-2 text-green-400" />
Товары ({order.items.length})
</h4>
<div className="space-y-2">
{order.items.map((item) => (
<div
key={item.id}
className="bg-white/5 rounded p-3 flex items-center justify-between"
>
<div className="flex-1 min-w-0">
<h5 className="text-white font-medium text-sm">
{item.product.name}
</h5>
<p className="text-white/60 text-xs">
Артикул: {item.product.article}
</p>
{item.product.category && (
<Badge
variant="secondary"
className="bg-blue-500/20 text-blue-300 text-xs mt-1"
>
{item.product.category.name}
</Badge>
)}
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className="text-white font-semibold">
{item.quantity} шт.
</p>
<p className="text-white/60 text-xs">
{formatCurrency(item.price)}
</p>
<p className="text-green-400 font-semibold text-sm">
{formatCurrency(item.totalPrice)}
</p>
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</Card>
))
)}
</div>
{/* Основной интерфейс заявок */}
<SupplierOrdersTabs />
</div>
{/* Модальное окно для отклонения заказа */}
{showRejectModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="bg-gray-900 border-white/20 p-6 max-w-md w-full mx-4">
<h3 className="text-white font-semibold text-lg mb-4">
Отклонить заказ
</h3>
<p className="text-white/60 text-sm mb-4">
Укажите причину отклонения заказа (необязательно):
</p>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Причина отклонения..."
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-transparent mb-4"
rows={3}
/>
<div className="flex items-center space-x-3">
<Button
onClick={() => handleRejectOrder(showRejectModal)}
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
>
Отклонить заказ
</Button>
<Button
onClick={() => {
setShowRejectModal(null);
setRejectReason("");
}}
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
>
Отмена
</Button>
</div>
</Card>
</div>
)}
</main>
</div>
);
}
}

View 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>
);
}

View 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>
);
}

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "@apollo/client";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { useAuth } from "@/hooks/useAuth";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -77,6 +78,7 @@ interface SelectedConsumable {
export function CreateConsumablesSupplyPage() {
const router = useRouter();
const { user } = useAuth();
const { getSidebarMargin } = useSidebar();
const [selectedSupplier, setSelectedSupplier] =
useState<ConsumableSupplier | null>(null);
@ -88,6 +90,8 @@ export function CreateConsumablesSupplyPage() {
const [deliveryDate, setDeliveryDate] = useState("");
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
useState<ConsumableSupplier | null>(null);
const [selectedLogistics, setSelectedLogistics] =
useState<ConsumableSupplier | null>(null);
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
// Загружаем контрагентов-поставщиков расходников
@ -117,6 +121,11 @@ export function CreateConsumablesSupplyPage() {
counterpartiesData?.myCounterparties || []
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
// Фильтруем логистические компании
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
(org: ConsumableSupplier) => org.type === "LOGIST"
);
// Фильтруем поставщиков по поисковому запросу
const filteredSuppliers = consumableSuppliers.filter(
(supplier: ConsumableSupplier) =>
@ -218,19 +227,82 @@ export function CreateConsumablesSupplyPage() {
selectedConsumables.length === 0 ||
!deliveryDate
) {
toast.error("Заполните все обязательные поля");
toast.error(
"Заполните все обязательные поля: поставщик, расходники и дата доставки"
);
return;
}
// Для селлеров требуется выбор фулфилмент-центра
// TODO: Добавить проверку типа текущей организации
if (!selectedFulfillmentCenter) {
toast.error("Выберите фулфилмент-центр для доставки");
return;
}
// Логистика опциональна - может выбрать селлер или оставить фулфилменту
if (selectedLogistics && !selectedLogistics.id) {
toast.error("Некорректно выбрана логистическая компания");
return;
}
// Дополнительные проверки
if (!selectedFulfillmentCenter.id) {
toast.error("ID фулфилмент-центра не найден");
return;
}
if (!selectedSupplier.id) {
toast.error("ID поставщика не найден");
return;
}
if (selectedConsumables.length === 0) {
toast.error("Не выбраны расходники");
return;
}
// Проверяем дату
const deliveryDateObj = new Date(deliveryDate);
if (isNaN(deliveryDateObj.getTime())) {
toast.error("Некорректная дата поставки");
return;
}
setIsCreatingSupply(true);
// 🔍 ОТЛАДКА: проверяем текущего пользователя
console.log("👤 Текущий пользователь:", {
userId: user?.id,
phone: user?.phone,
organizationId: user?.organization?.id,
organizationType: user?.organization?.type,
organizationName:
user?.organization?.name || user?.organization?.fullName,
});
console.log("🚀 Создаем поставку с данными:", {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
logisticsPartnerId: selectedLogistics?.id,
hasLogistics: !!selectedLogistics?.id,
consumableType: "SELLER_CONSUMABLES",
itemsCount: selectedConsumables.length,
mutationInput: {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
...(selectedLogistics?.id
? { logisticsPartnerId: selectedLogistics.id }
: {}),
consumableType: "SELLER_CONSUMABLES",
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
})),
},
});
try {
const result = await createSupplyOrder({
variables: {
@ -238,6 +310,12 @@ export function CreateConsumablesSupplyPage() {
partnerId: selectedSupplier.id,
deliveryDate: deliveryDate,
fulfillmentCenterId: selectedFulfillmentCenter.id,
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
...(selectedLogistics?.id
? { logisticsPartnerId: selectedLogistics.id }
: {}),
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
consumableType: "SELLER_CONSUMABLES", // Расходники селлеров
items: selectedConsumables.map((consumable) => ({
productId: consumable.id,
quantity: consumable.selectedQuantity,
@ -270,7 +348,21 @@ export function CreateConsumablesSupplyPage() {
}
} catch (error) {
console.error("Error creating consumables supply:", error);
toast.error("Ошибка при создании поставки расходников");
// Детальная диагностика ошибки
if (error instanceof Error) {
console.error("Error details:", {
message: error.message,
stack: error.stack,
name: error.name,
});
// Показываем конкретную ошибку пользователю
toast.error(`Ошибка: ${error.message}`);
} else {
console.error("Unknown error:", error);
toast.error("Ошибка при создании поставки расходников");
}
} finally {
setIsCreatingSupply(false);
}
@ -764,7 +856,7 @@ export function CreateConsumablesSupplyPage() {
);
setSelectedFulfillmentCenter(center || null);
}}
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
required
>
<option value="" className="bg-gray-800 text-white">
@ -782,8 +874,73 @@ export function CreateConsumablesSupplyPage() {
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg
className="w-4 h-4 text-white/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
{/* БЛОК ВЫБОРА ЛОГИСТИЧЕСКОЙ КОМПАНИИ */}
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">
Логистическая компания:
<span className="text-white/40 ml-1">(опционально)</span>
</label>
<div className="relative">
<select
value={selectedLogistics?.id || ""}
onChange={(e) => {
const logisticsId = e.target.value;
const logistics = logisticsPartners.find(
(p) => p.id === logisticsId
);
setSelectedLogistics(logistics || null);
}}
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
>
<option value="" className="bg-gray-800 text-white">
Выберите логистику или оставьте фулфилменту
</option>
{logisticsPartners.map((partner) => (
<option
key={partner.id}
value={partner.id}
className="bg-gray-800 text-white"
>
{partner.name || partner.fullName || "Логистика"}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
<svg
className="w-4 h-4 text-white/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<div className="mb-3">
<label className="text-white/60 text-xs mb-1 block">
Дата поставки:

View File

@ -42,7 +42,15 @@ interface SupplyOrderItem {
interface SupplyOrder {
id: string;
deliveryDate: string;
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
status:
| "PENDING"
| "SUPPLIER_APPROVED"
| "CONFIRMED"
| "LOGISTICS_CONFIRMED"
| "SHIPPED"
| "IN_TRANSIT"
| "DELIVERED"
| "CANCELLED";
totalAmount: number;
totalItems: number;
createdAt: string;
@ -102,10 +110,22 @@ export function SellerSupplyOrdersTab() {
label: "Ожидает одобрения",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
SUPPLIER_APPROVED: {
label: "Одобрена поставщиком",
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
},
CONFIRMED: {
label: "Одобрена",
label: "Подтверждена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
LOGISTICS_CONFIRMED: {
label: "Готова к отправке",
color: "bg-teal-500/20 text-teal-300 border-teal-500/30",
},
SHIPPED: {
label: "Отправлена",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
IN_TRANSIT: {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
@ -119,7 +139,16 @@ export function SellerSupplyOrdersTab() {
color: "bg-red-500/20 text-red-300 border-red-500/30",
},
};
const { label, color } = statusMap[status];
const config = statusMap[status as keyof typeof statusMap];
if (!config) {
// Fallback для неизвестных статусов
return (
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{status}
</Badge>
);
}
const { label, color } = config;
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
};

View File

@ -42,7 +42,27 @@ export function SuppliesDashboard() {
});
const pendingCount = pendingData?.pendingSuppliesCount;
const hasPendingItems = pendingCount && pendingCount.total > 0;
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
const hasPendingItems = (() => {
if (!pendingCount) return false;
switch (user?.organization?.type) {
case "SELLER":
// Селлеры не получают уведомления о поставках - только отслеживают статус
return false;
case "WHOLESALE":
// Поставщики видят только входящие заказы, не заявки на партнерство
return pendingCount.incomingSupplierOrders > 0;
case "FULFILLMENT":
// Фулфилмент видит только поставки к обработке, не заявки на партнерство
return pendingCount.supplyOrders > 0;
case "LOGIST":
// Логистика видит только логистические заявки, не заявки на партнерство
return pendingCount.logisticsOrders > 0;
default:
return pendingCount.total > 0;
}
})();
// Автоматически открываем нужную вкладку при загрузке
useEffect(() => {
@ -69,32 +89,33 @@ export function SuppliesDashboard() {
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
У вас есть {pendingCount.total} элемент
{pendingCount.total > 1
? pendingCount.total < 5
? "а"
: "ов"
: ""}
, требующ{pendingCount.total > 1 ? "их" : "ий"} одобрения:
{pendingCount.supplyOrders > 0 &&
` ${pendingCount.supplyOrders} заказ${
pendingCount.supplyOrders > 1
? pendingCount.supplyOrders < 5
? "а"
: "ов"
: ""
} поставок`}
{pendingCount.incomingRequests > 0 &&
pendingCount.supplyOrders > 0 &&
", "}
{pendingCount.incomingRequests > 0 &&
` ${pendingCount.incomingRequests} заявк${
pendingCount.incomingRequests > 1
? pendingCount.incomingRequests < 5
? "и"
: ""
: "а"
} на партнерство`}
{(() => {
switch (user?.organization?.type) {
case "WHOLESALE":
const orders = pendingCount.incomingSupplierOrders || 0;
return `У вас ${orders} входящ${
orders > 1 ? (orders < 5 ? "их" : "их") : "ий"
} заказ${
orders > 1 ? (orders < 5 ? "а" : "ов") : ""
} от клиентов, ожидающ${
orders > 1 ? "их" : "ий"
} подтверждения`;
case "FULFILLMENT":
const supplies = pendingCount.supplyOrders || 0;
return `У вас ${supplies} поставк${
supplies > 1 ? (supplies < 5 ? "и" : "ов") : "а"
} к обработке`;
case "LOGIST":
const logistics = pendingCount.logisticsOrders || 0;
return `У вас ${logistics} логистическ${
logistics > 1 ? (logistics < 5 ? "их" : "их") : "ая"
} заявк${
logistics > 1 ? (logistics < 5 ? "и" : "и") : "а"
} к подтверждению`;
default:
return `У вас есть элементы, требующие внимания`;
}
})()}
</AlertDescription>
</Alert>
)}

View 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
View 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;
}

View File

@ -746,6 +746,42 @@ export const CREATE_SUPPLY_ORDER = gql`
}
`;
// Мутация для назначения логистики на поставку фулфилментом
export const ASSIGN_LOGISTICS_TO_SUPPLY = gql`
mutation AssignLogisticsToSupply(
$supplyOrderId: ID!
$logisticsPartnerId: ID!
$responsibleId: ID
) {
assignLogisticsToSupply(
supplyOrderId: $supplyOrderId
logisticsPartnerId: $logisticsPartnerId
responsibleId: $responsibleId
) {
success
message
order {
id
status
logisticsPartnerId
responsibleId
logisticsPartner {
id
name
fullName
type
}
responsible {
id
firstName
lastName
email
}
}
}
}
`;
// Мутации для логистики
export const CREATE_LOGISTICS = gql`
mutation CreateLogistics($input: LogisticsInput!) {
@ -924,8 +960,16 @@ export const RELEASE_PRODUCT_RESERVE = gql`
// Мутация для обновления статуса "в пути"
export const UPDATE_PRODUCT_IN_TRANSIT = gql`
mutation UpdateProductInTransit($productId: ID!, $quantity: Int!, $operation: String!) {
updateProductInTransit(productId: $productId, quantity: $quantity, operation: $operation) {
mutation UpdateProductInTransit(
$productId: ID!
$quantity: Int!
$operation: String!
) {
updateProductInTransit(
productId: $productId
quantity: $quantity
operation: $operation
) {
success
message
product {

View File

@ -111,6 +111,44 @@ export const GET_MY_FULFILLMENT_SUPPLIES = gql`
}
`;
export const GET_SELLER_SUPPLIES_ON_WAREHOUSE = gql`
query GetSellerSuppliesOnWarehouse {
sellerSuppliesOnWarehouse {
id
name
description
price
quantity
unit
category
status
date
supplier
minStock
currentStock
usedStock
imageUrl
type
shopLocation
createdAt
updatedAt
organization {
id
name
fullName
type
}
sellerOwner {
id
name
fullName
inn
type
}
}
}
`;
export const GET_MY_LOGISTICS = gql`
query GetMyLogistics {
myLogistics {
@ -122,6 +160,25 @@ export const GET_MY_LOGISTICS = gql`
description
createdAt
updatedAt
organization {
id
name
fullName
}
}
}
`;
export const GET_LOGISTICS_PARTNERS = gql`
query GetLogisticsPartners {
logisticsPartners {
id
name
fullName
type
address
phones
emails
}
}
`;
@ -607,6 +664,8 @@ export const GET_MY_EMPLOYEES = gql`
firstName
lastName
middleName
fullName
name
birthDate
avatar
passportSeries
@ -927,6 +986,7 @@ export const GET_SUPPLY_ORDERS = gql`
supplyOrders {
id
organizationId
partnerId
deliveryDate
status
totalAmount
@ -1023,11 +1083,7 @@ export const GET_SELLER_STATS_CACHE = gql`
$dateFrom: String
$dateTo: String
) {
getSellerStatsCache(
period: $period
dateFrom: $dateFrom
dateTo: $dateTo
) {
getSellerStatsCache(period: $period, dateFrom: $dateFrom, dateTo: $dateTo) {
success
message
fromCache

View File

@ -900,7 +900,7 @@ export const resolvers = {
}
// Возвращаем заказы где текущая организация является заказчиком, поставщиком, получателем или логистическим партнером
return await prisma.supplyOrder.findMany({
const orders = await prisma.supplyOrder.findMany({
where: {
OR: [
{ organizationId: currentUser.organization.id }, // Заказы созданные организацией
@ -939,6 +939,8 @@ export const resolvers = {
},
orderBy: { createdAt: "desc" },
});
return orders;
},
// Счетчик поставок, требующих одобрения
@ -969,12 +971,17 @@ export const resolvers = {
},
});
// Расходники селлеров (созданные другими для нас) - требуют подтверждения получения
// Расходники селлеров (созданные другими для нас) - требуют действий фулфилмента
const sellerSupplyOrders = await prisma.supplyOrder.count({
where: {
fulfillmentCenterId: currentUser.organization.id, // Получатель - мы
organizationId: { not: currentUser.organization.id }, // Создали НЕ мы
status: "IN_TRANSIT", // В пути - нужно подтвердить получение
status: {
in: [
"SUPPLIER_APPROVED", // Поставщик подтвердил - нужно назначить логистику
"IN_TRANSIT", // В пути - нужно подтвердить получение
],
},
},
});
@ -986,9 +993,30 @@ export const resolvers = {
},
});
// Общий счетчик поставок
const pendingSupplyOrders =
ourSupplyOrders + sellerSupplyOrders + incomingSupplierOrders;
// 🚚 ЛОГИСТИЧЕСКИЕ ЗАЯВКИ ДЛЯ ЛОГИСТИКИ (LOGIST) - требуют действий логистики
const logisticsOrders = await prisma.supplyOrder.count({
where: {
logisticsPartnerId: currentUser.organization.id, // Мы - назначенная логистика
status: {
in: [
"CONFIRMED", // Подтверждено фулфилментом - нужно подтвердить логистикой
"LOGISTICS_CONFIRMED", // Подтверждено логистикой - нужно забрать товар у поставщика
],
},
},
});
// Общий счетчик поставок в зависимости от типа организации
let pendingSupplyOrders = 0;
if (currentUser.organization.type === "FULFILLMENT") {
pendingSupplyOrders = ourSupplyOrders + sellerSupplyOrders;
} else if (currentUser.organization.type === "WHOLESALE") {
pendingSupplyOrders = incomingSupplierOrders;
} else if (currentUser.organization.type === "LOGIST") {
pendingSupplyOrders = logisticsOrders;
} else if (currentUser.organization.type === "SELLER") {
pendingSupplyOrders = 0; // Селлеры не подтверждают поставки, только отслеживают
}
// Считаем входящие заявки на партнерство со статусом PENDING
const pendingIncomingRequests = await prisma.counterpartyRequest.count({
@ -1003,6 +1031,7 @@ export const resolvers = {
ourSupplyOrders: ourSupplyOrders, // Расходники фулфилмента
sellerSupplyOrders: sellerSupplyOrders, // Расходники селлеров
incomingSupplierOrders: incomingSupplierOrders, // 🔔 Входящие заказы для поставщиков
logisticsOrders: logisticsOrders, // 🚚 Логистические заявки для логистики
incomingRequests: pendingIncomingRequests,
total: pendingSupplyOrders + pendingIncomingRequests,
};
@ -1146,9 +1175,11 @@ export const resolvers = {
);
// Подсчитываем количество из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем только расходники фулфилмента, исключаем расходники селлеров
const fulfillmentSuppliesFromWarehouse = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "FULFILLMENT_CONSUMABLES", // ТОЛЬКО расходники фулфилмента
},
});
@ -1203,39 +1234,40 @@ export const resolvers = {
`📊 FULFILLMENT SUPPLIES RECEIVED TODAY (ПРИБЫЛО): ${fulfillmentSuppliesReceivedToday.length} orders, ${fulfillmentSuppliesChangeToday} items`
);
// Расходники селлеров - получаем из заказов от селлеров (расходники = CONSUMABLE)
// Согласно правилам: селлеры заказывают расходники у поставщиков и доставляют на склад фулфилмента
const sellerSuppliesCount = sellerDeliveredOrders.reduce(
(sum, order) =>
sum +
order.items.reduce(
(itemSum, item) =>
itemSum +
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
0
),
// Расходники селлеров - получаем из таблицы Supply (актуальные остатки на складе фулфилмента)
// ИСПРАВЛЕНО: считаем из Supply с типом SELLER_CONSUMABLES
const sellerSuppliesFromWarehouse = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
},
});
const sellerSuppliesCount = sellerSuppliesFromWarehouse.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
0
);
console.log(
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from delivered orders)`
`💼 SELLER SUPPLIES DEBUG: totalCount=${sellerSuppliesCount} (from Supply warehouse)`
);
// Изменения расходников селлеров за сутки - используем уже полученные данные
const sellerSuppliesChangeToday = recentSellerDeliveredOrders.reduce(
(sum, order) =>
sum +
order.items.reduce(
(itemSum, item) =>
itemSum +
(item.product.type === "CONSUMABLE" ? item.quantity : 0),
0
),
// Изменения расходников селлеров за сутки - считаем из Supply записей, созданных за сутки
const sellerSuppliesReceivedToday = await prisma.supply.findMany({
where: {
organizationId: organizationId, // Склад фулфилмента
type: "SELLER_CONSUMABLES", // ТОЛЬКО расходники селлеров
createdAt: { gte: oneDayAgo }, // Созданы за последние сутки
},
});
const sellerSuppliesChangeToday = sellerSuppliesReceivedToday.reduce(
(sum, supply) => sum + (supply.currentStock || 0),
0
);
console.log(
`📊 SELLER SUPPLIES RECEIVED TODAY: ${recentSellerDeliveredOrders.length} orders, ${sellerSuppliesChangeToday} items`
`📊 SELLER SUPPLIES RECEIVED TODAY: ${sellerSuppliesReceivedToday.length} supplies, ${sellerSuppliesChangeToday} items`
);
// Вычисляем процентные изменения
@ -1327,6 +1359,24 @@ export const resolvers = {
});
},
// Логистические партнеры (организации-логисты)
logisticsPartners: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
// Получаем все организации типа LOGIST
return await prisma.organization.findMany({
where: {
type: "LOGIST",
// Убираем фильтр по статусу пока не определим правильные значения
},
orderBy: { createdAt: "desc" }, // Сортируем по дате создания вместо name
});
},
// Мои поставки Wildberries
myWildberriesSupplies: async (
_: unknown,
@ -1358,6 +1408,94 @@ export const resolvers = {
});
},
// Расходники селлеров на складе фулфилмента (новый resolver)
sellerSuppliesOnWarehouse: async (
_: unknown,
__: unknown,
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
// Только фулфилмент может получать расходники селлеров на своем складе
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступ разрешен только для фулфилмент-центров");
}
// ИСПРАВЛЕНО: Усиленная фильтрация расходников селлеров
const sellerSupplies = await prisma.supply.findMany({
where: {
organizationId: currentUser.organization.id, // На складе этого фулфилмента
type: "SELLER_CONSUMABLES" as const, // Только расходники селлеров
sellerOwnerId: { not: null }, // ОБЯЗАТЕЛЬНО должен быть владелец-селлер
},
include: {
organization: true, // Фулфилмент-центр (хранитель)
sellerOwner: true, // Селлер-владелец расходников
},
orderBy: { createdAt: "desc" },
});
// Логирование для отладки
console.log(
"🔍 ИСПРАВЛЕНО: Запрос расходников селлеров на складе фулфилмента:",
{
fulfillmentId: currentUser.organization.id,
fulfillmentName: currentUser.organization.name,
totalSupplies: sellerSupplies.length,
sellerSupplies: sellerSupplies.map((supply) => ({
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
sellerOwnerName:
supply.sellerOwner?.name || supply.sellerOwner?.fullName,
currentStock: supply.currentStock,
})),
}
);
// ДВОЙНАЯ ПРОВЕРКА: Фильтруем на уровне кода для гарантии
const filteredSupplies = sellerSupplies.filter((supply) => {
const isValid =
supply.type === "SELLER_CONSUMABLES" &&
supply.sellerOwnerId != null &&
supply.sellerOwner != null;
if (!isValid) {
console.warn("⚠️ ОТФИЛЬТРОВАН некорректный расходник:", {
id: supply.id,
name: supply.name,
type: supply.type,
sellerOwnerId: supply.sellerOwnerId,
hasSellerOwner: !!supply.sellerOwner,
});
}
return isValid;
});
console.log("✅ ФИНАЛЬНЫЙ РЕЗУЛЬТАТ после фильтрации:", {
originalCount: sellerSupplies.length,
filteredCount: filteredSupplies.length,
removedCount: sellerSupplies.length - filteredSupplies.length,
});
return filteredSupplies;
},
// Мои товары и расходники (для поставщиков)
myProducts: async (_: unknown, __: unknown, context: Context) => {
console.log("🔍 MY_PRODUCTS RESOLVER - ВЫЗВАН:", {
@ -1830,34 +1968,52 @@ export const resolvers = {
// Сотрудники организации
myEmployees: async (_: unknown, __: unknown, context: Context) => {
console.log("🔍 myEmployees resolver called");
if (!context.user) {
console.log("❌ No user in context for myEmployees");
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
console.log("✅ User authenticated for myEmployees:", context.user.id);
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
try {
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
console.log("❌ User has no organization");
throw new GraphQLError("У пользователя нет организации");
}
console.log(
"📊 User organization type:",
currentUser.organization.type
);
if (currentUser.organization.type !== "FULFILLMENT") {
console.log("❌ Not a fulfillment center");
throw new GraphQLError("Доступно только для фулфилмент центров");
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true,
},
orderBy: { createdAt: "desc" },
});
console.log("👥 Found employees:", employees.length);
return employees;
} catch (error) {
console.error("❌ Error in myEmployees resolver:", error);
throw error;
}
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Доступно только для фулфилмент центров");
}
const employees = await prisma.employee.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true,
},
orderBy: { createdAt: "desc" },
});
return employees;
},
// Получение сотрудника по ID
@ -3937,7 +4093,19 @@ export const resolvers = {
include: { organization: true },
});
if (!currentUser?.organization) {
console.log("🔍 Проверка пользователя:", {
userId: context.user.id,
userFound: !!currentUser,
organizationFound: !!currentUser?.organization,
organizationType: currentUser?.organization?.type,
organizationId: currentUser?.organization?.id,
});
if (!currentUser) {
throw new GraphQLError("Пользователь не найден");
}
if (!currentUser.organization) {
throw new GraphQLError("У пользователя нет организации");
}
@ -4067,21 +4235,34 @@ export const resolvers = {
initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы
}
const supplyOrder = await prisma.supplyOrder.create({
data: {
partnerId: args.input.partnerId,
deliveryDate: new Date(args.input.deliveryDate),
totalAmount: new Prisma.Decimal(totalAmount),
totalItems: totalItems,
organizationId: currentUser.organization.id,
fulfillmentCenterId: fulfillmentCenterId,
logisticsPartnerId: args.input.logisticsPartnerId,
consumableType: args.input.consumableType, // Классификация расходников
status: initialStatus,
items: {
create: orderItems,
},
// Подготавливаем данные для создания заказа
const createData: any = {
partnerId: args.input.partnerId,
deliveryDate: new Date(args.input.deliveryDate),
totalAmount: new Prisma.Decimal(totalAmount),
totalItems: totalItems,
organizationId: currentUser.organization.id,
fulfillmentCenterId: fulfillmentCenterId,
consumableType: args.input.consumableType,
status: initialStatus,
items: {
create: orderItems,
},
};
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: добавляем только если передана
if (args.input.logisticsPartnerId) {
createData.logisticsPartnerId = args.input.logisticsPartnerId;
}
console.log("🔍 Создаем SupplyOrder с данными:", {
hasLogistics: !!args.input.logisticsPartnerId,
logisticsId: args.input.logisticsPartnerId,
createData: createData,
});
const supplyOrder = await prisma.supplyOrder.create({
data: createData,
include: {
partner: {
include: {
@ -5961,17 +6142,13 @@ export const resolvers = {
});
if (product) {
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
const currentStock = product.stock || product.quantity || 0;
const newStock = Math.max(currentStock - item.quantity, 0);
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
// Остаток уже был уменьшен при создании/одобрении заказа
await prisma.product.update({
where: { id: item.product.id },
data: {
// Обновляем основные остатки (УБЫЛО)
stock: newStock,
quantity: newStock, // Синхронизируем оба поля для совместимости
// Обновляем дополнительные значения
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
// Только переводим из inTransit в sold
inTransit: Math.max(
(product.inTransit || 0) - item.quantity,
0
@ -5980,7 +6157,11 @@ export const resolvers = {
},
});
console.log(
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц (остаток: ${currentStock} -> ${newStock})`
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${
item.quantity
} единиц (остаток НЕ ИЗМЕНЕН: ${
product.stock || product.quantity || 0
})`
);
}
}
@ -6073,6 +6254,117 @@ export const resolvers = {
}
},
// Назначение логистики фулфилментом на заказ селлера
assignLogisticsToSupply: async (
_: unknown,
args: {
supplyOrderId: string;
logisticsPartnerId: string;
responsibleId?: string;
},
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
// Проверяем, что пользователь - фулфилмент
if (currentUser.organization.type !== "FULFILLMENT") {
throw new GraphQLError("Только фулфилмент может назначать логистику");
}
try {
// Находим заказ
const existingOrder = await prisma.supplyOrder.findUnique({
where: { id: args.supplyOrderId },
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
if (!existingOrder) {
throw new GraphQLError("Заказ поставки не найден");
}
// Проверяем, что это заказ для нашего фулфилмент-центра
if (existingOrder.fulfillmentCenterId !== currentUser.organization.id) {
throw new GraphQLError("Нет доступа к этому заказу");
}
// Проверяем, что статус позволяет назначить логистику
if (existingOrder.status !== "SUPPLIER_APPROVED") {
throw new GraphQLError(
`Нельзя назначить логистику для заказа со статусом ${existingOrder.status}`
);
}
// Проверяем, что логистическая компания существует
const logisticsPartner = await prisma.organization.findUnique({
where: { id: args.logisticsPartnerId },
});
if (!logisticsPartner || logisticsPartner.type !== "LOGIST") {
throw new GraphQLError("Логистическая компания не найдена");
}
// Обновляем заказ
const updatedOrder = await prisma.supplyOrder.update({
where: { id: args.supplyOrderId },
data: {
logisticsPartner: {
connect: { id: args.logisticsPartnerId },
},
status: "CONFIRMED", // Переводим в статус "подтвержден фулфилментом"
},
include: {
partner: true,
fulfillmentCenter: true,
logisticsPartner: true,
items: {
include: { product: true },
},
},
});
console.log(`✅ Логистика назначена на заказ ${args.supplyOrderId}:`, {
logisticsPartner: logisticsPartner.name,
responsible: args.responsibleId,
newStatus: "CONFIRMED",
});
return {
success: true,
message: "Логистика успешно назначена",
order: updatedOrder,
};
} catch (error) {
console.error("❌ Ошибка при назначении логистики:", error);
return {
success: false,
message:
error instanceof Error
? error.message
: "Ошибка при назначении логистики",
};
}
},
// Резолверы для новых действий с заказами поставок
supplierApproveOrder: async (
_: unknown,
@ -6458,7 +6750,7 @@ export const resolvers = {
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
status: "SUPPLIER_APPROVED",
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
@ -6530,7 +6822,7 @@ export const resolvers = {
where: {
id: args.id,
logisticsPartnerId: currentUser.organization.id,
status: "SUPPLIER_APPROVED",
OR: [{ status: "SUPPLIER_APPROVED" }, { status: "CONFIRMED" }],
},
});
@ -6608,9 +6900,15 @@ export const resolvers = {
include: {
items: {
include: {
product: true,
product: {
include: {
category: true,
},
},
},
},
organization: true, // Селлер-создатель заказа
partner: true, // Поставщик
},
});
@ -6651,17 +6949,13 @@ export const resolvers = {
});
if (product) {
// Согласно правилам: Основные значения = Предыдущие остатки + Прибыло - Убыло
const currentStock = product.stock || product.quantity || 0;
const newStock = Math.max(currentStock - item.quantity, 0);
// ИСПРАВЛЕНО: НЕ списываем повторно, только переводим из inTransit в sold
// Остаток уже был уменьшен при создании/одобрении заказа
await prisma.product.update({
where: { id: item.product.id },
data: {
// Обновляем основные остатки (УБЫЛО)
stock: newStock,
quantity: newStock, // Синхронизируем оба поля для совместимости
// Обновляем дополнительные значения
// НЕ ТРОГАЕМ stock - он уже правильно уменьшен при заказе
// Только переводим из inTransit в sold
inTransit: Math.max(
(product.inTransit || 0) - item.quantity,
0
@ -6670,33 +6964,62 @@ export const resolvers = {
},
});
console.log(
`✅ Товар поставщика "${product.name}" обновлен: доставлено ${item.quantity} единиц`
`✅ Товар поставщика "${product.name}" обновлен: получено ${item.quantity} единиц`
);
console.log(
` 📊 Остатки: ${currentStock} -> ${newStock} (УБЫЛО: ${item.quantity})`
` 📊 Остаток: ${
product.stock || product.quantity || 0
} (НЕ ИЗМЕНЕН - уже списан при заказе)`
);
console.log(
` 🚚 В пути: ${product.inTransit || 0} -> ${Math.max(
(product.inTransit || 0) - item.quantity,
0
)}`
)} (УБЫЛО: ${item.quantity})`
);
console.log(
` 💰 Продано: ${product.sold || 0} -> ${
(product.sold || 0) + item.quantity
}`
} (ПРИБЫЛО: ${item.quantity})`
);
}
}
// Обновляем склад фулфилмента
// Обновляем склад фулфилмента с учетом типа расходников
console.log("📦 Обновляем склад фулфилмента...");
console.log(
`🏷️ Тип поставки: ${
existingOrder.consumableType || "FULFILLMENT_CONSUMABLES"
}`
);
for (const item of existingOrder.items) {
// Определяем тип расходников и владельца
const isSellerSupply =
existingOrder.consumableType === "SELLER_CONSUMABLES";
const supplyType = isSellerSupply
? "SELLER_CONSUMABLES"
: "FULFILLMENT_CONSUMABLES";
const sellerOwnerId = isSellerSupply
? updatedOrder.organization?.id
: null;
// Для расходников селлеров ищем по имени И по владельцу
const whereCondition = isSellerSupply
? {
organizationId: currentUser.organization.id,
name: item.product.name,
type: "SELLER_CONSUMABLES" as const,
sellerOwnerId: sellerOwnerId,
}
: {
organizationId: currentUser.organization.id,
name: item.product.name,
type: "FULFILLMENT_CONSUMABLES" as const,
};
const existingSupply = await prisma.supply.findFirst({
where: {
organizationId: currentUser.organization.id,
name: item.product.name,
},
where: whereCondition,
});
if (existingSupply) {
@ -6709,9 +7032,13 @@ export const resolvers = {
},
});
console.log(
`📈 Обновлен существующий расходник фулфилмента "${
item.product.name
}": ${existingSupply.currentStock} -> ${
`📈 Обновлен существующий ${
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
} "${item.product.name}" ${
isSellerSupply
? `(владелец: ${updatedOrder.organization?.name})`
: ""
}: ${existingSupply.currentStock} -> ${
existingSupply.currentStock + item.quantity
}`
);
@ -6719,9 +7046,13 @@ export const resolvers = {
await prisma.supply.create({
data: {
name: item.product.name,
description:
item.product.description ||
`Расходники от ${updatedOrder.partner.name}`,
description: isSellerSupply
? `Расходники селлера ${
updatedOrder.organization?.name ||
updatedOrder.organization?.fullName
}`
: item.product.description ||
`Расходники от ${updatedOrder.partner.name}`,
price: item.price,
quantity: item.quantity,
currentStock: item.quantity,
@ -6733,11 +7064,21 @@ export const resolvers = {
updatedOrder.partner.name ||
updatedOrder.partner.fullName ||
"Поставщик",
type: supplyType as
| "SELLER_CONSUMABLES"
| "FULFILLMENT_CONSUMABLES",
sellerOwnerId: sellerOwnerId,
organizationId: currentUser.organization.id,
},
});
console.log(
` Создан новый расходник фулфилмента "${item.product.name}": ${item.quantity} единиц`
` Создан новый ${
isSellerSupply ? "расходник селлера" : "расходник фулфилмента"
} "${item.product.name}" ${
isSellerSupply
? `(владелец: ${updatedOrder.organization?.name})`
: ""
}: ${item.quantity} единиц`
);
}
}
@ -6849,7 +7190,10 @@ export const resolvers = {
// Иначе загружаем отдельно
return await prisma.supply.findMany({
where: { organizationId: parent.id },
include: { organization: true },
include: {
organization: true,
sellerOwner: true, // Включаем информацию о селлере-владельце
},
orderBy: { createdAt: "desc" },
});
},
@ -6949,6 +7293,20 @@ export const resolvers = {
},
Employee: {
fullName: (parent: {
firstName: string;
lastName: string;
middleName?: string;
}) => {
const parts = [parent.lastName, parent.firstName];
if (parent.middleName) {
parts.push(parent.middleName);
}
return parts.join(" ");
},
name: (parent: { firstName: string; lastName: string }) => {
return `${parent.firstName} ${parent.lastName}`;
},
birthDate: (parent: { birthDate?: Date | string | null }) => {
if (!parent.birthDate) return null;
if (parent.birthDate instanceof Date) {

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const authResolvers = {
Query: {},
Mutation: {},
};

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const employeeResolvers = {
Query: {},
Mutation: {},
};

View 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,
}
);

View 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: "Ошибка при отклонении заказа",
};
}
},
},
};

View File

@ -0,0 +1,6 @@
import { Context } from "../context";
export const suppliesResolvers = {
Query: {},
Mutation: {},
};

51
src/graphql/scalars.ts Normal file
View 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;
},
});

View File

@ -43,6 +43,9 @@ export const typeDefs = gql`
# Расходники фулфилмента (материалы для работы фулфилмента)
myFulfillmentSupplies: [Supply!]!
# Расходники селлеров на складе фулфилмента (только для фулфилмента)
sellerSuppliesOnWarehouse: [Supply!]!
# Заказы поставок расходников
supplyOrders: [SupplyOrder!]!
@ -52,6 +55,9 @@ export const typeDefs = gql`
# Логистика организации
myLogistics: [Logistics!]!
# Логистические партнеры (организации-логисты)
logisticsPartners: [Organization!]!
# Поставки Wildberries
myWildberriesSupplies: [WildberriesSupply!]!
@ -206,16 +212,23 @@ export const typeDefs = gql`
id: ID!
status: SupplyOrderStatus!
): SupplyOrderResponse!
# Назначение логистики фулфилментом
assignLogisticsToSupply(
supplyOrderId: ID!
logisticsPartnerId: ID!
responsibleId: ID
): SupplyOrderResponse!
# Действия поставщика
supplierApproveOrder(id: ID!): SupplyOrderResponse!
supplierRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
supplierShipOrder(id: ID!): SupplyOrderResponse!
# Действия логиста
logisticsConfirmOrder(id: ID!): SupplyOrderResponse!
logisticsRejectOrder(id: ID!, reason: String): SupplyOrderResponse!
# Действия фулфилмента
fulfillmentReceiveOrder(id: ID!): SupplyOrderResponse!
@ -228,12 +241,19 @@ export const typeDefs = gql`
createProduct(input: ProductInput!): ProductResponse!
updateProduct(id: ID!, input: ProductInput!): ProductResponse!
deleteProduct(id: ID!): Boolean!
# Валидация и управление остатками товаров
checkArticleUniqueness(article: String!, excludeId: ID): ArticleUniquenessResponse!
checkArticleUniqueness(
article: String!
excludeId: ID
): ArticleUniquenessResponse!
reserveProductStock(productId: ID!, quantity: Int!): ProductStockResponse!
releaseProductReserve(productId: ID!, quantity: Int!): ProductStockResponse!
updateProductInTransit(productId: ID!, quantity: Int!, operation: String!): ProductStockResponse!
updateProductInTransit(
productId: ID!
quantity: Int!
operation: String!
): ProductStockResponse!
# Работа с категориями
createCategory(input: CategoryInput!): CategoryResponse!
@ -535,6 +555,11 @@ export const typeDefs = gql`
}
# Типы для расходников
enum SupplyType {
FULFILLMENT_CONSUMABLES # Расходники фулфилмента (купленные фулфилментом для себя)
SELLER_CONSUMABLES # Расходники селлеров (принятые от селлеров для хранения)
}
type Supply {
id: ID!
name: String!
@ -550,6 +575,9 @@ export const typeDefs = gql`
currentStock: Int
usedStock: Int
imageUrl: String
type: SupplyType!
sellerOwner: Organization # Селлер-владелец (для расходников селлеров)
shopLocation: String # Местоположение в магазине фулфилмента
createdAt: DateTime!
updatedAt: DateTime!
organization: Organization!
@ -594,8 +622,8 @@ export const typeDefs = gql`
totalItems: Int!
fulfillmentCenterId: ID
fulfillmentCenter: Organization
logisticsPartnerId: ID!
logisticsPartner: Organization!
logisticsPartnerId: ID
logisticsPartner: Organization
items: [SupplyOrderItem!]!
createdAt: DateTime!
updatedAt: DateTime!
@ -612,21 +640,21 @@ export const typeDefs = gql`
}
enum SupplyOrderStatus {
PENDING # Ожидает одобрения поставщика
CONFIRMED # Устаревший статус (для обратной совместимости)
IN_TRANSIT # Устаревший статус (для обратной совместимости)
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
SHIPPED # Отправлено поставщиком, в пути
DELIVERED # Доставлено и принято фулфилментом
CANCELLED # Отменено (любой участник может отменить)
PENDING # Ожидает одобрения поставщика
CONFIRMED # Устаревший статус (для обратной совместимости)
IN_TRANSIT # Устаревший статус (для обратной совместимости)
SUPPLIER_APPROVED # Поставщик одобрил, ожидает подтверждения логистики
LOGISTICS_CONFIRMED # Логистика подтвердила, ожидает отправки
SHIPPED # Отправлено поставщиком, в пути
DELIVERED # Доставлено и принято фулфилментом
CANCELLED # Отменено (любой участник может отменить)
}
input SupplyOrderInput {
partnerId: ID!
deliveryDate: DateTime!
fulfillmentCenterId: ID # ID фулфилмент-центра для доставки
logisticsPartnerId: ID! # ID логистической компании (обязательно)
logisticsPartnerId: ID # ID логистической компании (опционально - может выбрать селлер или фулфилмент)
items: [SupplyOrderItemInput!]!
notes: String # Дополнительные заметки к заказу
consumableType: String # Классификация расходников: FULFILLMENT_CONSUMABLES, SELLER_CONSUMABLES
@ -642,6 +670,7 @@ export const typeDefs = gql`
ourSupplyOrders: Int! # Расходники фулфилмента
sellerSupplyOrders: Int! # Расходники селлеров
incomingSupplierOrders: Int! # 🔔 Входящие заказы для поставщиков
logisticsOrders: Int! # 🚚 Логистические заявки для логистики
incomingRequests: Int!
total: Int!
}
@ -819,6 +848,8 @@ export const typeDefs = gql`
firstName: String!
lastName: String!
middleName: String
fullName: String
name: String
birthDate: DateTime
avatar: String
passportPhoto: String

View File

@ -1,52 +1,79 @@
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
import {
ApolloClient,
InMemoryCache,
createHttpLink,
from,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { onError } from "@apollo/client/link/error";
// HTTP Link для GraphQL запросов
const httpLink = createHttpLink({
uri: '/api/graphql',
})
uri: "/api/graphql",
});
// Auth Link для добавления JWT токена в заголовки
const authLink = setContext((operation, { headers }) => {
if (typeof window === 'undefined') {
return { headers }
if (typeof window === "undefined") {
return { headers };
}
// Проверяем токены администратора и пользователя
const adminToken = localStorage.getItem('adminAuthToken')
const userToken = localStorage.getItem('authToken')
const adminToken = localStorage.getItem("adminAuthToken");
const userToken = localStorage.getItem("authToken");
// Приоритет у админского токена
const token = adminToken || userToken
const tokenType = adminToken ? 'admin' : 'user'
console.log(`Apollo Client - Operation: ${operation.operationName}, Token type: ${tokenType}, Token:`, token ? `${token.substring(0, 20)}...` : 'No token')
const token = adminToken || userToken;
const tokenType = adminToken ? "admin" : "user";
console.log(
`Apollo Client - Operation: ${operation.operationName}, Token type: ${tokenType}, Token:`,
token ? `${token.substring(0, 20)}...` : "No token"
);
const authHeaders = {
...headers,
authorization: token ? `Bearer ${token}` : '',
}
console.log('Apollo Client - Auth headers:', { authorization: authHeaders.authorization ? 'Bearer ***' : 'No auth' })
authorization: token ? `Bearer ${token}` : "",
};
console.log("Apollo Client - Auth headers:", {
authorization: authHeaders.authorization ? "Bearer ***" : "No auth",
});
return {
headers: authHeaders
}
})
headers: authHeaders,
};
});
// Error Link для обработки ошибок - минимальная версия
const errorLink = onError(() => {
// Пустой обработчик - не делаем ничего
// Это предотвращает любые ошибки в error handler
})
// Error Link для обработки ошибок с детальным логированием
const errorLink = onError(
({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
console.error("🚨 GraphQL Error:", {
message,
locations,
path,
extensions,
operation: operation.operationName,
variables: operation.variables,
});
});
}
if (networkError) {
console.error("🌐 Network Error:", {
error: networkError,
operation: operation.operationName,
variables: operation.variables,
});
}
}
);
// Создаем Apollo Client
export const apolloClient = new ApolloClient({
link: from([
authLink,
httpLink,
]),
link: from([errorLink, authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
User: {
@ -67,45 +94,45 @@ export const apolloClient = new ApolloClient({
}),
defaultOptions: {
watchQuery: {
errorPolicy: 'all',
errorPolicy: "all",
},
query: {
errorPolicy: 'all',
errorPolicy: "all",
},
},
})
});
// Утилитарные функции для работы с токеном и пользователем
export const setAuthToken = (token: string) => {
if (typeof window !== 'undefined') {
localStorage.setItem('authToken', token)
if (typeof window !== "undefined") {
localStorage.setItem("authToken", token);
}
}
};
export const removeAuthToken = () => {
if (typeof window !== 'undefined') {
localStorage.removeItem('authToken')
localStorage.removeItem('userData')
if (typeof window !== "undefined") {
localStorage.removeItem("authToken");
localStorage.removeItem("userData");
}
}
};
export const getAuthToken = (): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem('authToken')
if (typeof window !== "undefined") {
return localStorage.getItem("authToken");
}
return null
}
return null;
};
export const setUserData = (userData: unknown) => {
if (typeof window !== 'undefined') {
localStorage.setItem('userData', JSON.stringify(userData))
if (typeof window !== "undefined") {
localStorage.setItem("userData", JSON.stringify(userData));
}
}
};
export const getUserData = (): unknown | null => {
if (typeof window !== 'undefined') {
const data = localStorage.getItem('userData')
return data ? JSON.parse(data) : null
if (typeof window !== "undefined") {
const data = localStorage.getItem("userData");
return data ? JSON.parse(data) : null;
}
return null
}
return null;
};