Обновлены модели и компоненты для управления поставками и расходниками. Добавлены новые поля в модели SupplyOrder и соответствующие резолверы для поддержки логистики. Реализованы компоненты уведомлений для отображения статуса логистических заявок и поставок. Оптимизирован интерфейс для улучшения пользовательского опыта, добавлены логи для диагностики запросов. Обновлены GraphQL схемы и мутации для поддержки новых функциональных возможностей.
This commit is contained in:
@ -48,6 +48,65 @@ function PendingSuppliesNotification() {
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения логистических заявок (только для логистики)
|
||||
function LogisticsOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const logisticsCount =
|
||||
pendingData?.pendingSuppliesCount?.logisticsOrders || 0;
|
||||
|
||||
if (logisticsCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{logisticsCount > 99 ? "99+" : logisticsCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
|
||||
function FulfillmentSuppliesNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const suppliesCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
||||
|
||||
if (suppliesCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{suppliesCount > 99 ? "99+" : suppliesCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
|
||||
function WholesaleOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
|
||||
const ordersCount =
|
||||
pendingData?.pendingSuppliesCount?.incomingSupplierOrders || 0;
|
||||
|
||||
if (ordersCount === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center font-bold animate-pulse">
|
||||
{ordersCount > 99 ? "99+" : ordersCount}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
@ -149,7 +208,7 @@ export function Sidebar() {
|
||||
router.push("/supplies");
|
||||
break;
|
||||
case "WHOLESALE":
|
||||
router.push("/supplies");
|
||||
router.push("/supplier-orders");
|
||||
break;
|
||||
case "LOGIST":
|
||||
router.push("/logistics-orders");
|
||||
@ -202,7 +261,8 @@ export function Sidebar() {
|
||||
const isSuppliesActive =
|
||||
pathname.startsWith("/supplies") ||
|
||||
pathname.startsWith("/fulfillment-supplies") ||
|
||||
pathname.startsWith("/logistics");
|
||||
pathname.startsWith("/logistics") ||
|
||||
pathname.startsWith("/supplier-orders");
|
||||
const isPartnersActive = pathname.startsWith("/partners");
|
||||
|
||||
return (
|
||||
@ -475,8 +535,7 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Мои поставки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Селлеры не получают уведомления о поставках - только отслеживают статус */}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -536,8 +595,8 @@ export function Sidebar() {
|
||||
{!isCollapsed && (
|
||||
<span className="ml-3">Входящие поставки</span>
|
||||
)}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о поставках, не о заявках на партнерство */}
|
||||
<FulfillmentSuppliesNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -595,8 +654,8 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Заявки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о входящих заказах поставок, не о заявках на партнерство */}
|
||||
<WholesaleOrdersNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -616,8 +675,8 @@ export function Sidebar() {
|
||||
>
|
||||
<Truck className="h-4 w-4 flex-shrink-0" />
|
||||
{!isCollapsed && <span className="ml-3">Перевозки</span>}
|
||||
{/* Уведомление о непринятых поставках */}
|
||||
<PendingSuppliesNotification />
|
||||
{/* Уведомление только о логистических заявках */}
|
||||
<LogisticsOrdersNotification />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -1,86 +1,104 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { apolloClient } from '@/lib/apollo-client'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { apolloClient } from "@/lib/apollo-client";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { EmployeeInlineForm } from './employee-inline-form'
|
||||
import { EmployeeCompactForm } from './employee-compact-form'
|
||||
import { EmployeeEditInlineForm } from './employee-edit-inline-form'
|
||||
import { EmployeeInlineForm } from "./employee-inline-form";
|
||||
import { EmployeeCompactForm } from "./employee-compact-form";
|
||||
import { EmployeeEditInlineForm } from "./employee-edit-inline-form";
|
||||
|
||||
import { EmployeeSearch } from './employee-search'
|
||||
import { EmployeeLegend } from './employee-legend'
|
||||
import { EmployeeEmptyState } from './employee-empty-state'
|
||||
import { EmployeeRow } from './employee-row'
|
||||
import { EmployeeReports } from './employee-reports'
|
||||
import { toast } from 'sonner'
|
||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from '@/graphql/queries'
|
||||
import { CREATE_EMPLOYEE, UPDATE_EMPLOYEE, DELETE_EMPLOYEE, UPDATE_EMPLOYEE_SCHEDULE } from '@/graphql/mutations'
|
||||
import { Users, FileText, Plus, Layout, LayoutGrid } from 'lucide-react'
|
||||
import { EmployeeSearch } from "./employee-search";
|
||||
import { EmployeeLegend } from "./employee-legend";
|
||||
import { EmployeeEmptyState } from "./employee-empty-state";
|
||||
import { EmployeeRow } from "./employee-row";
|
||||
import { EmployeeReports } from "./employee-reports";
|
||||
import { toast } from "sonner";
|
||||
import { GET_MY_EMPLOYEES, GET_EMPLOYEE_SCHEDULE } from "@/graphql/queries";
|
||||
import {
|
||||
CREATE_EMPLOYEE,
|
||||
UPDATE_EMPLOYEE,
|
||||
DELETE_EMPLOYEE,
|
||||
UPDATE_EMPLOYEE_SCHEDULE,
|
||||
} from "@/graphql/mutations";
|
||||
import { Users, FileText, Plus, Layout, LayoutGrid } from "lucide-react";
|
||||
|
||||
// Интерфейс сотрудника
|
||||
interface Employee {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
middleName?: string
|
||||
position: string
|
||||
phone: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
hireDate: string
|
||||
status: 'ACTIVE' | 'VACATION' | 'SICK' | 'FIRED'
|
||||
salary?: number
|
||||
address?: string
|
||||
birthDate?: string
|
||||
passportSeries?: string
|
||||
passportNumber?: string
|
||||
passportIssued?: string
|
||||
passportDate?: string
|
||||
emergencyContact?: string
|
||||
emergencyPhone?: string
|
||||
telegram?: string
|
||||
whatsapp?: string
|
||||
passportPhoto?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
position: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
hireDate: string;
|
||||
status: "ACTIVE" | "VACATION" | "SICK" | "FIRED";
|
||||
salary?: number;
|
||||
address?: string;
|
||||
birthDate?: string;
|
||||
passportSeries?: string;
|
||||
passportNumber?: string;
|
||||
passportIssued?: string;
|
||||
passportDate?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
passportPhoto?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function EmployeesDashboard() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [showCompactForm, setShowCompactForm] = useState(true) // По умолчанию компактная форма
|
||||
const [showEditForm, setShowEditForm] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null)
|
||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(null)
|
||||
const [employeeSchedules, setEmployeeSchedules] = useState<{[key: string]: ScheduleRecord[]}>({})
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear())
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth())
|
||||
const [activeTab, setActiveTab] = useState('combined')
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showCompactForm, setShowCompactForm] = useState(true); // По умолчанию компактная форма
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
|
||||
const [deletingEmployeeId, setDeletingEmployeeId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [employeeSchedules, setEmployeeSchedules] = useState<{
|
||||
[key: string]: ScheduleRecord[];
|
||||
}>({});
|
||||
const [currentYear, setCurrentYear] = useState(new Date().getFullYear());
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date().getMonth());
|
||||
const [activeTab, setActiveTab] = useState("combined");
|
||||
|
||||
interface ScheduleRecord {
|
||||
id: string
|
||||
date: string
|
||||
status: string
|
||||
hoursWorked?: number
|
||||
id: string;
|
||||
date: string;
|
||||
status: string;
|
||||
hoursWorked?: number;
|
||||
employee: {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// GraphQL запросы и мутации
|
||||
const { data, loading, refetch } = useQuery(GET_MY_EMPLOYEES)
|
||||
const [createEmployee] = useMutation(CREATE_EMPLOYEE)
|
||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE)
|
||||
const [deleteEmployee] = useMutation(DELETE_EMPLOYEE)
|
||||
const [updateEmployeeSchedule] = useMutation(UPDATE_EMPLOYEE_SCHEDULE)
|
||||
|
||||
const 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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -99,14 +99,44 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
GET_MY_COUNTERPARTIES
|
||||
);
|
||||
|
||||
// ОТЛАДКА: Логируем состояние перед запросом товаров
|
||||
console.log("🔍 ДИАГНОСТИКА ЗАПРОСА ТОВАРОВ:", {
|
||||
selectedSupplier: selectedSupplier
|
||||
? {
|
||||
id: selectedSupplier.id,
|
||||
name: selectedSupplier.name || selectedSupplier.fullName,
|
||||
type: selectedSupplier.type,
|
||||
}
|
||||
: null,
|
||||
skipQuery: !selectedSupplier,
|
||||
productSearchQuery,
|
||||
});
|
||||
|
||||
// Загружаем товары для выбранного поставщика
|
||||
const { data: productsData, loading: productsLoading } = useQuery(
|
||||
GET_ALL_PRODUCTS,
|
||||
{
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
}
|
||||
);
|
||||
const {
|
||||
data: productsData,
|
||||
loading: productsLoading,
|
||||
error: productsError,
|
||||
} = useQuery(GET_ALL_PRODUCTS, {
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
onCompleted: (data) => {
|
||||
console.log("✅ GET_ALL_PRODUCTS COMPLETED:", {
|
||||
totalProducts: data?.allProducts?.length || 0,
|
||||
products:
|
||||
data?.allProducts?.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
orgId: p.organization?.id,
|
||||
orgName: p.organization?.name,
|
||||
})) || [],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("❌ GET_ALL_PRODUCTS ERROR:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Мутация для создания заказа поставки расходников
|
||||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
|
||||
@ -117,9 +147,9 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "WHOLESALE");
|
||||
|
||||
// Фильтруем только логистические компании
|
||||
const logisticsPartners = (
|
||||
counterpartiesData?.myCounterparties || []
|
||||
).filter((org: FulfillmentConsumableSupplier) => org.type === "LOGIST");
|
||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === "LOGIST"
|
||||
);
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = consumableSuppliers.filter(
|
||||
@ -150,6 +180,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
}
|
||||
: null,
|
||||
productsLoading,
|
||||
productsError: productsError?.message,
|
||||
allProductsCount: productsData?.allProducts?.length || 0,
|
||||
supplierProductsCount: supplierProducts.length,
|
||||
allProducts:
|
||||
@ -160,14 +191,20 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
organizationName: p.organization.name,
|
||||
type: p.type || "NO_TYPE",
|
||||
})) || [],
|
||||
supplierProducts: supplierProducts.map((p) => ({
|
||||
supplierProductsDetails: supplierProducts.slice(0, 5).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
organizationId: p.organization.id,
|
||||
organizationName: p.organization.name,
|
||||
})),
|
||||
});
|
||||
}, [selectedSupplier, productsData, productsLoading, supplierProducts]);
|
||||
}, [
|
||||
selectedSupplier,
|
||||
productsData,
|
||||
productsLoading,
|
||||
productsError,
|
||||
supplierProducts.length,
|
||||
]);
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
@ -198,10 +235,13 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
|
||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
||||
if (quantity > 0) {
|
||||
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0);
|
||||
|
||||
const availableStock =
|
||||
(product.stock || product.quantity || 0) - (product.ordered || 0);
|
||||
|
||||
if (quantity > availableStock) {
|
||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`);
|
||||
toast.error(
|
||||
`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -265,7 +305,15 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
!deliveryDate ||
|
||||
!selectedLogistics
|
||||
) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
toast.error(
|
||||
"Заполните все обязательные поля: поставщик, расходники, дата доставки и логистика"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Дополнительная проверка ID логистики
|
||||
if (!selectedLogistics.id) {
|
||||
toast.error("Выберите логистическую компанию");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -279,7 +327,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
deliveryDate: deliveryDate,
|
||||
// Для фулфилмента указываем себя как получателя (поставка на свой склад)
|
||||
fulfillmentCenterId: user?.organization?.id,
|
||||
logisticsPartnerId: selectedLogistics?.id,
|
||||
logisticsPartnerId: selectedLogistics.id,
|
||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||
consumableType: "FULFILLMENT_CONSUMABLES", // Расходники фулфилмента
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
@ -574,15 +622,19 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
|
||||
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0;
|
||||
const totalStock =
|
||||
product.stock || product.quantity || 0;
|
||||
const orderedStock = product.ordered || 0;
|
||||
const availableStock = totalStock - orderedStock;
|
||||
|
||||
const availableStock =
|
||||
totalStock - orderedStock;
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
||||
<div className="text-red-400 font-bold text-xs">
|
||||
НЕТ В НАЛИЧИИ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -636,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>
|
||||
|
@ -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>
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -12,8 +12,14 @@ import {
|
||||
GET_MY_SUPPLIES,
|
||||
GET_PENDING_SUPPLIES_COUNT,
|
||||
GET_WAREHOUSE_PRODUCTS,
|
||||
GET_MY_EMPLOYEES,
|
||||
GET_LOGISTICS_PARTNERS,
|
||||
} from "@/graphql/queries";
|
||||
import { UPDATE_SUPPLY_ORDER_STATUS } from "@/graphql/mutations";
|
||||
import {
|
||||
UPDATE_SUPPLY_ORDER_STATUS,
|
||||
ASSIGN_LOGISTICS_TO_SUPPLY,
|
||||
FULFILLMENT_RECEIVE_ORDER,
|
||||
} from "@/graphql/mutations";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@ -34,16 +40,36 @@ import {
|
||||
Store,
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
UserPlus,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
};
|
||||
organization?: {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
};
|
||||
partner: {
|
||||
id: string;
|
||||
inn: string;
|
||||
@ -83,31 +109,106 @@ interface SupplyOrder {
|
||||
|
||||
export function FulfillmentConsumablesOrdersTab() {
|
||||
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set());
|
||||
const [assigningOrders, setAssigningOrders] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<{
|
||||
[orderId: string]: string;
|
||||
}>({});
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<{
|
||||
[orderId: string]: string;
|
||||
}>({});
|
||||
const { user } = useAuth();
|
||||
|
||||
// Запросы данных
|
||||
const {
|
||||
data: employeesData,
|
||||
loading: employeesLoading,
|
||||
error: employeesError,
|
||||
} = useQuery(GET_MY_EMPLOYEES);
|
||||
const {
|
||||
data: logisticsData,
|
||||
loading: logisticsLoading,
|
||||
error: logisticsError,
|
||||
} = useQuery(GET_LOGISTICS_PARTNERS);
|
||||
|
||||
// Отладочная информация
|
||||
console.log("DEBUG EMPLOYEES:", {
|
||||
loading: employeesLoading,
|
||||
error: employeesError?.message,
|
||||
errorDetails: employeesError,
|
||||
data: employeesData,
|
||||
employees: employeesData?.myEmployees,
|
||||
});
|
||||
console.log("DEBUG LOGISTICS:", {
|
||||
loading: logisticsLoading,
|
||||
error: logisticsError?.message,
|
||||
errorDetails: logisticsError,
|
||||
data: logisticsData,
|
||||
partners: logisticsData?.logisticsPartners,
|
||||
});
|
||||
|
||||
// Логируем ошибки отдельно
|
||||
if (employeesError) {
|
||||
console.error("EMPLOYEES ERROR:", employeesError);
|
||||
}
|
||||
if (logisticsError) {
|
||||
console.error("LOGISTICS ERROR:", logisticsError);
|
||||
}
|
||||
|
||||
// Загружаем заказы поставок
|
||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS);
|
||||
|
||||
// Мутация для обновления статуса заказа
|
||||
const [updateSupplyOrderStatus, { loading: updating }] = useMutation(
|
||||
UPDATE_SUPPLY_ORDER_STATUS,
|
||||
// Мутация для приемки поставки фулфилментом
|
||||
const [fulfillmentReceiveOrder, { loading: receiving }] = useMutation(
|
||||
FULFILLMENT_RECEIVE_ORDER,
|
||||
{
|
||||
onCompleted: (data) => {
|
||||
if (data.updateSupplyOrderStatus.success) {
|
||||
toast.success(data.updateSupplyOrderStatus.message);
|
||||
if (data.fulfillmentReceiveOrder.success) {
|
||||
toast.success(data.fulfillmentReceiveOrder.message);
|
||||
refetch(); // Обновляем список заказов
|
||||
} else {
|
||||
toast.error(data.updateSupplyOrderStatus.message);
|
||||
toast.error(data.fulfillmentReceiveOrder.message);
|
||||
}
|
||||
},
|
||||
refetchQueries: [
|
||||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||||
{ query: GET_MY_SUPPLIES }, // Обновляем склад фулфилмента (расходники фулфилмента)
|
||||
{ query: GET_WAREHOUSE_PRODUCTS }, // Обновляем товары склада
|
||||
{ query: GET_PENDING_SUPPLIES_COUNT }, // Обновляем счетчики уведомлений
|
||||
],
|
||||
onError: (error) => {
|
||||
console.error("Error updating supply order status:", error);
|
||||
toast.error("Ошибка при обновлении статуса заказа");
|
||||
console.error("Error receiving supply order:", error);
|
||||
toast.error("Ошибка при приеме заказа поставки");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Мутация для назначения логистики и ответственного
|
||||
const [assignLogisticsToSupply, { loading: assigning }] = useMutation(
|
||||
ASSIGN_LOGISTICS_TO_SUPPLY,
|
||||
{
|
||||
onCompleted: (data) => {
|
||||
if (data.assignLogisticsToSupply.success) {
|
||||
toast.success("Логистика и ответственный назначены успешно");
|
||||
refetch(); // Обновляем список заказов
|
||||
// Сбрасываем состояние назначения
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(data.assignLogisticsToSupply.supplyOrder.id);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
data.assignLogisticsToSupply.message ||
|
||||
"Ошибка при назначении логистики"
|
||||
);
|
||||
}
|
||||
},
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onError: (error) => {
|
||||
console.error("Error assigning logistics:", error);
|
||||
toast.error("Ошибка при назначении логистики");
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -144,6 +245,19 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
number: fulfillmentOrders.length - index, // Обратный порядок для новых заказов сверху
|
||||
}));
|
||||
|
||||
// Автоматически открываем режим назначения для заказов, которые требуют назначения логистики
|
||||
React.useEffect(() => {
|
||||
fulfillmentOrders.forEach((order) => {
|
||||
if (canAssignLogistics(order) && !assigningOrders.has(order.id)) {
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(order.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [fulfillmentOrders]);
|
||||
|
||||
const getStatusBadge = (status: SupplyOrder["status"]) => {
|
||||
const statusMap = {
|
||||
PENDING: {
|
||||
@ -151,11 +265,26 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
icon: Clock,
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Подтверждена",
|
||||
SUPPLIER_APPROVED: {
|
||||
label: "Одобрено",
|
||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Подтверждена",
|
||||
color: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||
icon: CheckCircle,
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Логистика OK",
|
||||
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
icon: Truck,
|
||||
},
|
||||
SHIPPED: {
|
||||
label: "Отгружено",
|
||||
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
icon: Package,
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "В пути",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
@ -181,24 +310,69 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (
|
||||
orderId: string,
|
||||
newStatus: SupplyOrder["status"]
|
||||
) => {
|
||||
// Функция для приема заказа фулфилментом
|
||||
const handleReceiveOrder = async (orderId: string) => {
|
||||
try {
|
||||
await updateSupplyOrderStatus({
|
||||
variables: {
|
||||
id: orderId,
|
||||
status: newStatus,
|
||||
},
|
||||
await fulfillmentReceiveOrder({
|
||||
variables: { id: orderId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating status:", error);
|
||||
console.error("Error receiving order:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const canMarkAsDelivered = (status: SupplyOrder["status"]) => {
|
||||
return status === "IN_TRANSIT";
|
||||
// Проверяем, можно ли принять заказ (для фулфилмента)
|
||||
const canReceiveOrder = (status: SupplyOrder["status"]) => {
|
||||
return status === "SHIPPED";
|
||||
};
|
||||
|
||||
const toggleAssignmentMode = (orderId: string) => {
|
||||
setAssigningOrders((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(orderId)) {
|
||||
newSet.delete(orderId);
|
||||
} else {
|
||||
newSet.add(orderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignLogistics = async (orderId: string) => {
|
||||
const logisticsId = selectedLogistics[orderId];
|
||||
const employeeId = selectedEmployees[orderId];
|
||||
|
||||
if (!logisticsId) {
|
||||
toast.error("Выберите логистическую компанию");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employeeId) {
|
||||
toast.error("Выберите ответственного сотрудника");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignLogisticsToSupply({
|
||||
variables: {
|
||||
supplyOrderId: orderId,
|
||||
logisticsPartnerId: logisticsId,
|
||||
responsibleId: employeeId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error assigning logistics:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const canAssignLogistics = (order: SupplyOrder) => {
|
||||
// Можем назначать логистику если:
|
||||
// 1. Статус SUPPLIER_APPROVED (одобрено поставщиком) или CONFIRMED (подтвержден фулфилментом)
|
||||
// 2. Логистика еще не назначена
|
||||
return (
|
||||
(order.status === "SUPPLIER_APPROVED" || order.status === "CONFIRMED") &&
|
||||
!order.logisticsPartner
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@ -247,15 +421,15 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1 bg-blue-500/20 rounded">
|
||||
<Clock className="h-3 w-3 text-blue-400" />
|
||||
<div className="p-1 bg-green-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Ожидание</p>
|
||||
<p className="text-white/60 text-xs">Одобрено</p>
|
||||
<p className="text-sm font-bold text-white">
|
||||
{
|
||||
fulfillmentOrders.filter(
|
||||
(order) => order.status === "PENDING"
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
@ -265,8 +439,8 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1 bg-green-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-green-400" />
|
||||
<div className="p-1 bg-emerald-500/20 rounded">
|
||||
<CheckCircle className="h-3 w-3 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Подтверждено</p>
|
||||
@ -336,8 +510,18 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
ordersWithNumbers.map((order) => (
|
||||
<Card
|
||||
key={order.id}
|
||||
className="bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors cursor-pointer"
|
||||
onClick={() => toggleOrderExpansion(order.id)}
|
||||
className={`bg-white/10 backdrop-blur border-white/20 overflow-hidden hover:bg-white/15 transition-colors ${
|
||||
canAssignLogistics(order) && assigningOrders.has(order.id)
|
||||
? "cursor-default"
|
||||
: "cursor-pointer"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
!(canAssignLogistics(order) && assigningOrders.has(order.id))
|
||||
) {
|
||||
toggleOrderExpansion(order.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Компактная основная информация */}
|
||||
<div className="px-3 py-2">
|
||||
@ -427,53 +611,20 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
|
||||
{/* Правая часть - статус и действия */}
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<Badge
|
||||
className={`${
|
||||
order.status === "PENDING"
|
||||
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
|
||||
: order.status === "CONFIRMED"
|
||||
? "bg-green-500/20 text-green-300 border-green-500/30"
|
||||
: order.status === "IN_TRANSIT"
|
||||
? "bg-yellow-500/20 text-yellow-300 border-yellow-500/30"
|
||||
: order.status === "DELIVERED"
|
||||
? "bg-purple-500/20 text-purple-300 border-purple-500/30"
|
||||
: "bg-red-500/20 text-red-300 border-red-500/30"
|
||||
} border flex items-center gap-1 text-xs px-2 py-1`}
|
||||
>
|
||||
{order.status === "PENDING" && (
|
||||
<Clock className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "CONFIRMED" && (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "IN_TRANSIT" && (
|
||||
<Truck className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "DELIVERED" && (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "CANCELLED" && (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{order.status === "PENDING" && "Ожидание"}
|
||||
{order.status === "CONFIRMED" && "Подтверждена"}
|
||||
{order.status === "IN_TRANSIT" && "В пути"}
|
||||
{order.status === "DELIVERED" && "Доставлена"}
|
||||
{order.status === "CANCELLED" && "Отменена"}
|
||||
</Badge>
|
||||
{getStatusBadge(order.status)}
|
||||
|
||||
{canMarkAsDelivered(order.status) && (
|
||||
{canReceiveOrder(order.status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusUpdate(order.id, "DELIVERED");
|
||||
handleReceiveOrder(order.id);
|
||||
}}
|
||||
disabled={updating}
|
||||
disabled={receiving}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-7"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Получено
|
||||
Принять
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -528,6 +679,100 @@ export function FulfillmentConsumablesOrdersTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Назначение логистики и ответственного в одной строке */}
|
||||
{assigningOrders.has(order.id) && canAssignLogistics(order) && (
|
||||
<div className="mt-2 p-2 bg-blue-500/10 border border-blue-500/20 rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Иконка и заголовок */}
|
||||
<div className="flex items-center text-blue-300 text-xs font-medium whitespace-nowrap">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Назначить:
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedLogistics[order.id] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedLogistics((prev) => ({
|
||||
...prev,
|
||||
[order.id]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
{logisticsData?.logisticsPartners?.length > 0
|
||||
? "Выберите логистику"
|
||||
: "Нет логистики"}
|
||||
</option>
|
||||
{logisticsData?.logisticsPartners?.map(
|
||||
(logistics: any) => (
|
||||
<option
|
||||
key={logistics.id}
|
||||
value={logistics.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{logistics.name || logistics.fullName}
|
||||
</option>
|
||||
)
|
||||
) || []}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Выбор ответственного */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
value={selectedEmployees[order.id] || ""}
|
||||
onChange={(e) => {
|
||||
setSelectedEmployees((prev) => ({
|
||||
...prev,
|
||||
[order.id]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white text-xs rounded px-2 py-1 focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
{employeesData?.myEmployees?.length > 0
|
||||
? "Выберите ответственного"
|
||||
: "Нет сотрудников"}
|
||||
</option>
|
||||
{employeesData?.myEmployees?.map((employee: any) => (
|
||||
<option
|
||||
key={employee.id}
|
||||
value={employee.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{employee.fullName || employee.name}
|
||||
</option>
|
||||
)) || []}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAssignLogistics(order.id)}
|
||||
disabled={assigning}
|
||||
className="bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30 text-xs px-2 py-1 h-6"
|
||||
>
|
||||
<UserPlus className="h-3 w-3 mr-1" />
|
||||
Принять
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleAssignmentMode(order.id)}
|
||||
className="border-white/20 text-white/60 hover:bg-white/10 text-xs px-2 py-1 h-6"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Развернутые детали заказа */}
|
||||
{expandedOrders.has(order.id) && (
|
||||
<>
|
||||
|
@ -33,8 +33,6 @@ import {
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
|
||||
|
||||
// Интерфейс для заказа
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
@ -174,10 +172,14 @@ export function FulfillmentDetailedSuppliesTab() {
|
||||
// "Расходники фулфилмента" = расходники, которые МЫ (фулфилмент-центр) заказали для себя
|
||||
// Критерии: создатель = мы И получатель = мы (ОБА условия)
|
||||
const ourSupplyOrders: SupplyOrder[] = (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => {
|
||||
(order: any) => {
|
||||
// Защита от null/undefined значений
|
||||
return (
|
||||
order.organizationId === currentOrganizationId && // Создали мы
|
||||
order.fulfillmentCenterId === currentOrganizationId // Получатель - мы
|
||||
order?.organizationId === currentOrganizationId && // Создали мы
|
||||
order?.fulfillmentCenterId === currentOrganizationId && // Получатель - мы
|
||||
order?.organization && // Проверяем наличие organization
|
||||
order?.partner && // Проверяем наличие partner
|
||||
Array.isArray(order?.items) // Проверяем наличие items
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -248,7 +250,9 @@ export function FulfillmentDetailedSuppliesTab() {
|
||||
{/* Заголовок с кнопкой создания поставки */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-1">Расходники фулфилмента</h2>
|
||||
<h2 className="text-xl font-bold text-white mb-1">
|
||||
Расходники фулфилмента
|
||||
</h2>
|
||||
<p className="text-white/60 text-sm">
|
||||
Поставки расходников, поступающие на склад фулфилмент-центра
|
||||
</p>
|
||||
|
@ -40,8 +40,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
|
||||
|
||||
// Интерфейсы для данных
|
||||
interface Employee {
|
||||
id: string;
|
||||
@ -662,52 +660,50 @@ export function FulfillmentGoodsTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-2 xl:p-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
{/* Вкладки товаров */}
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-4">
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<Clock className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Новые</span>
|
||||
<span className="sm:hidden">Н</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="receiving"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<FileText className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Приёмка</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="received"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 xl:gap-2 text-xs xl:text-sm"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 xl:h-4 xl:w-4" />
|
||||
<span className="hidden sm:inline">Принято</span>
|
||||
<span className="sm:hidden">Пр</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="space-y-3">
|
||||
{/* УРОВЕНЬ 3: Подподтабы (маленький размер, больший отступ) */}
|
||||
<div className="ml-8">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border-white/15 h-8 rounded-md p-1 mb-3">
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Новые</span>
|
||||
<span className="sm:hidden">Н</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="receiving"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Приёмка</span>
|
||||
<span className="sm:hidden">П</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="received"
|
||||
className="data-[state=active]:bg-white/10 data-[state=active]:text-white text-white/50 hover:text-white/70 flex items-center gap-1 text-xs font-normal transition-all duration-150 rounded-sm"
|
||||
>
|
||||
<CheckCircle className="h-2.5 w-2.5" />
|
||||
<span className="hidden sm:inline">Принято</span>
|
||||
<span className="sm:hidden">Пр</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="new" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="new" />
|
||||
</TabsContent>
|
||||
<TabsContent value="new" className="space-y-0">
|
||||
<TabContent tabName="new" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="receiving" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="receiving" />
|
||||
</TabsContent>
|
||||
<TabsContent value="receiving" className="space-y-0">
|
||||
<TabContent tabName="receiving" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="received" className="flex-1 overflow-hidden">
|
||||
<TabContent tabName="received" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="received" className="space-y-0">
|
||||
<TabContent tabName="received" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -32,13 +32,33 @@ export function FulfillmentSuppliesTab() {
|
||||
const [activeTab, setActiveTab] = useState("goods");
|
||||
|
||||
// Загружаем данные о непринятых поставках
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
});
|
||||
const { data: pendingData, error: pendingError } = useQuery(
|
||||
GET_PENDING_SUPPLIES_COUNT,
|
||||
{
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
fetchPolicy: "cache-first",
|
||||
errorPolicy: "ignore",
|
||||
onError: (error) => {
|
||||
console.error(
|
||||
"❌ GET_PENDING_SUPPLIES_COUNT Error in FulfillmentSuppliesTab:",
|
||||
error
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.total || 0;
|
||||
// Логируем ошибку для диагностики
|
||||
React.useEffect(() => {
|
||||
if (pendingError) {
|
||||
console.error(
|
||||
"🚨 Ошибка загрузки счетчиков в FulfillmentSuppliesTab:",
|
||||
pendingError
|
||||
);
|
||||
}
|
||||
}, [pendingError]);
|
||||
|
||||
// ✅ ПРАВИЛЬНО: Для фулфилмента считаем только поставки, НЕ заявки на партнерство
|
||||
const pendingCount = pendingData?.pendingSuppliesCount?.supplyOrders || 0;
|
||||
const ourSupplyOrdersCount =
|
||||
pendingData?.pendingSuppliesCount?.ourSupplyOrders || 0;
|
||||
const sellerSupplyOrdersCount =
|
||||
@ -66,74 +86,74 @@ export function FulfillmentSuppliesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-8 xl:h-10 mb-2 xl:mb-3 mx-2 xl:mx-4 mt-2 xl:mt-4">
|
||||
<TabsTrigger
|
||||
value="goods"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs"
|
||||
>
|
||||
<Package className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden sm:inline">Товар</span>
|
||||
<span className="sm:hidden">Т</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="detailed-supplies"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
|
||||
>
|
||||
<Building2 className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||
<span className="sm:hidden">Ф</span>
|
||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="consumables"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs relative"
|
||||
>
|
||||
<Wrench className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden md:inline">Расходники селлеров</span>
|
||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||
<span className="sm:hidden">С</span>
|
||||
<NotificationBadge count={sellerSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="returns"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 flex items-center gap-1 text-[10px] xl:text-xs"
|
||||
>
|
||||
<RotateCcw className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
|
||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||
<span className="sm:hidden">В</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="goods" className="flex-1 overflow-hidden">
|
||||
<FulfillmentGoodsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="detailed-supplies"
|
||||
className="flex-1 overflow-hidden"
|
||||
<div className="space-y-3">
|
||||
{/* УРОВЕНЬ 2: Подтабы (средний размер, отступ показывает иерархию) */}
|
||||
<div className="ml-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
||||
<FulfillmentDetailedSuppliesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsList className="grid w-full grid-cols-4 bg-white/8 backdrop-blur border-white/20 h-9 rounded-lg p-1 mb-3">
|
||||
<TabsTrigger
|
||||
value="goods"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Товар</span>
|
||||
<span className="sm:hidden">Т</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="detailed-supplies"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
|
||||
>
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="hidden md:inline">Расходники фулфилмента</span>
|
||||
<span className="md:hidden hidden sm:inline">Фулфилмент</span>
|
||||
<span className="sm:hidden">Ф</span>
|
||||
<NotificationBadge count={ourSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="consumables"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 relative rounded-md"
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<span className="hidden md:inline">Расходники селлеров</span>
|
||||
<span className="md:hidden hidden sm:inline">Селлеры</span>
|
||||
<span className="sm:hidden">С</span>
|
||||
<NotificationBadge count={sellerSupplyOrdersCount} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="returns"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white data-[state=active]:border-white/20 text-white/60 hover:text-white/80 flex items-center gap-1 text-xs font-medium transition-all duration-150 rounded-md"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Возвраты с ПВЗ</span>
|
||||
<span className="sm:hidden">В</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="consumables" className="flex-1 overflow-hidden">
|
||||
<div className="h-full p-2 xl:p-4 overflow-y-auto">
|
||||
<FulfillmentConsumablesOrdersTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="goods" className="space-y-0">
|
||||
<FulfillmentGoodsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="returns" className="flex-1 overflow-hidden">
|
||||
<PvzReturnsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="detailed-supplies" className="space-y-0">
|
||||
<div className="p-4">
|
||||
<FulfillmentDetailedSuppliesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consumables" className="space-y-0">
|
||||
<div className="p-4">
|
||||
<FulfillmentConsumablesOrdersTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="returns" className="space-y-0">
|
||||
<PvzReturnsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@ -15,7 +20,8 @@ import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_SUPPLY_ORDERS,
|
||||
GET_WAREHOUSE_PRODUCTS,
|
||||
GET_MY_SUPPLIES, // Расходники селлеров
|
||||
GET_MY_SUPPLIES, // Расходники селлеров (старые данные заказов)
|
||||
GET_SELLER_SUPPLIES_ON_WAREHOUSE, // Расходники селлеров на складе (новый API)
|
||||
GET_MY_FULFILLMENT_SUPPLIES, // Расходники фулфилмента
|
||||
GET_FULFILLMENT_WAREHOUSE_STATS, // Статистика склада с изменениями за сутки
|
||||
} from "@/graphql/queries";
|
||||
@ -55,6 +61,7 @@ interface ProductVariant {
|
||||
defectsQuantity: number;
|
||||
sellerSuppliesPlace?: string;
|
||||
sellerSuppliesQuantity: number;
|
||||
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||
pvzReturnsPlace?: string;
|
||||
pvzReturnsQuantity: number;
|
||||
}
|
||||
@ -72,6 +79,7 @@ interface ProductItem {
|
||||
defectsQuantity: number;
|
||||
sellerSuppliesPlace?: string;
|
||||
sellerSuppliesQuantity: number;
|
||||
sellerSuppliesOwners?: string[]; // Владельцы расходников
|
||||
pvzReturnsPlace?: string;
|
||||
pvzReturnsQuantity: number;
|
||||
// Третий уровень - варианты товара
|
||||
@ -200,13 +208,13 @@ export function FulfillmentWarehouseDashboard() {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
// Загружаем расходники селлеров
|
||||
// Загружаем расходники селлеров на складе фулфилмента
|
||||
const {
|
||||
data: suppliesData,
|
||||
loading: suppliesLoading,
|
||||
error: suppliesError,
|
||||
refetch: refetchSupplies,
|
||||
} = useQuery(GET_MY_SUPPLIES, {
|
||||
data: sellerSuppliesData,
|
||||
loading: sellerSuppliesLoading,
|
||||
error: sellerSuppliesError,
|
||||
refetch: refetchSellerSupplies,
|
||||
} = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
@ -246,8 +254,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
goods: warehouseStatsData.fulfillmentWarehouseStats.goods,
|
||||
defects: warehouseStatsData.fulfillmentWarehouseStats.defects,
|
||||
pvzReturns: warehouseStatsData.fulfillmentWarehouseStats.pvzReturns,
|
||||
fulfillmentSupplies: warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
||||
sellerSupplies: warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
||||
fulfillmentSupplies:
|
||||
warehouseStatsData.fulfillmentWarehouseStats.fulfillmentSupplies,
|
||||
sellerSupplies:
|
||||
warehouseStatsData.fulfillmentWarehouseStats.sellerSupplies,
|
||||
});
|
||||
}
|
||||
|
||||
@ -258,7 +268,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
);
|
||||
const supplyOrders: SupplyOrder[] = ordersData?.supplyOrders || [];
|
||||
const allProducts = productsData?.warehouseProducts || [];
|
||||
const mySupplies = suppliesData?.mySupplies || []; // Расходники селлеров
|
||||
const sellerSupplies = sellerSuppliesData?.sellerSuppliesOnWarehouse || []; // Расходники селлеров на складе
|
||||
const myFulfillmentSupplies =
|
||||
fulfillmentSuppliesData?.myFulfillmentSupplies || []; // Расходники фулфилмента
|
||||
|
||||
@ -276,8 +286,8 @@ export function FulfillmentWarehouseDashboard() {
|
||||
deliveredOrders: supplyOrders.filter((o) => o.status === "DELIVERED")
|
||||
.length,
|
||||
productsCount: allProducts.length,
|
||||
suppliesCount: mySupplies.length, // Добавляем логирование расходников
|
||||
supplies: mySupplies.map((s: any) => ({
|
||||
suppliesCount: sellerSupplies.length, // Добавляем логирование расходников
|
||||
supplies: sellerSupplies.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
currentStock: s.currentStock,
|
||||
@ -293,7 +303,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
})),
|
||||
// Добавляем анализ соответствия товаров и расходников
|
||||
productSupplyMatching: allProducts.map((product: any) => {
|
||||
const matchingSupply = mySupplies.find((supply: any) => {
|
||||
const matchingSupply = sellerSupplies.find((supply: any) => {
|
||||
return (
|
||||
supply.name.toLowerCase() === product.name.toLowerCase() ||
|
||||
supply.name
|
||||
@ -311,11 +321,11 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading,
|
||||
ordersLoading,
|
||||
productsLoading,
|
||||
suppliesLoading, // Добавляем статус загрузки расходников
|
||||
sellerSuppliesLoading, // Добавляем статус загрузки расходников селлеров
|
||||
counterpartiesError: counterpartiesError?.message,
|
||||
ordersError: ordersError?.message,
|
||||
productsError: productsError?.message,
|
||||
suppliesError: suppliesError?.message, // Добавляем ошибки загрузки расходников
|
||||
sellerSuppliesError: sellerSuppliesError?.message, // Добавляем ошибки загрузки расходников селлеров
|
||||
});
|
||||
|
||||
// Расчет поступлений расходников за сутки (выносим отдельно для использования в storeData)
|
||||
@ -408,7 +418,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
console.log("📊 Статистика расходников селлера:", {
|
||||
suppliesReceivedToday,
|
||||
suppliesUsedToday,
|
||||
totalSellerSupplies: mySupplies.reduce(
|
||||
totalSellerSupplies: sellerSupplies.reduce(
|
||||
(sum: number, supply: any) => sum + (supply.currentStock || 0),
|
||||
0
|
||||
),
|
||||
@ -418,7 +428,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
// Получаем статистику склада из GraphQL (с реальными изменениями за сутки)
|
||||
const warehouseStats: WarehouseStats = useMemo(() => {
|
||||
// Если данные еще загружаются, возвращаем нули
|
||||
if (warehouseStatsLoading || !warehouseStatsData?.fulfillmentWarehouseStats) {
|
||||
if (
|
||||
warehouseStatsLoading ||
|
||||
!warehouseStatsData?.fulfillmentWarehouseStats
|
||||
) {
|
||||
return {
|
||||
products: { current: 0, change: 0 },
|
||||
goods: { current: 0, change: 0 },
|
||||
@ -511,26 +524,64 @@ export function FulfillmentWarehouseDashboard() {
|
||||
}
|
||||
});
|
||||
|
||||
// Группируем расходники по названию
|
||||
const groupedSupplies = new Map<string, number>();
|
||||
mySupplies.forEach((supply: any) => {
|
||||
// ИСПРАВЛЕНО: Группируем расходники по СЕЛЛЕРУ-ВЛАДЕЛЬЦУ, а не по названию
|
||||
const suppliesByOwner = new Map<
|
||||
string,
|
||||
Map<string, { quantity: number; ownerName: string }>
|
||||
>();
|
||||
|
||||
sellerSupplies.forEach((supply: any) => {
|
||||
const ownerId = supply.sellerOwner?.id;
|
||||
const ownerName =
|
||||
supply.sellerOwner?.name ||
|
||||
supply.sellerOwner?.fullName ||
|
||||
"Неизвестный селлер";
|
||||
const supplyName = supply.name;
|
||||
const currentStock = supply.currentStock || 0;
|
||||
const supplyType = supply.type;
|
||||
|
||||
if (groupedSupplies.has(supplyName)) {
|
||||
groupedSupplies.set(
|
||||
supplyName,
|
||||
groupedSupplies.get(supplyName)! + currentStock
|
||||
// ИСПРАВЛЕНО: Строгая проверка согласно правилам
|
||||
if (!ownerId || supplyType !== "SELLER_CONSUMABLES") {
|
||||
console.warn(
|
||||
"⚠️ ОТФИЛЬТРОВАН расходник в компоненте (нарушение правил):",
|
||||
{
|
||||
id: supply.id,
|
||||
name: supplyName,
|
||||
type: supplyType,
|
||||
ownerId,
|
||||
ownerName,
|
||||
reason: !ownerId
|
||||
? "нет sellerOwner.id"
|
||||
: "тип не SELLER_CONSUMABLES",
|
||||
}
|
||||
);
|
||||
return; // Пропускаем согласно ПРАВИЛУ 6 из секции 11.6
|
||||
}
|
||||
|
||||
// Инициализируем группу для селлера, если её нет
|
||||
if (!suppliesByOwner.has(ownerId)) {
|
||||
suppliesByOwner.set(ownerId, new Map());
|
||||
}
|
||||
|
||||
const ownerSupplies = suppliesByOwner.get(ownerId)!;
|
||||
|
||||
if (ownerSupplies.has(supplyName)) {
|
||||
// Суммируем количество, если расходник уже есть у этого селлера
|
||||
const existing = ownerSupplies.get(supplyName)!;
|
||||
existing.quantity += currentStock;
|
||||
} else {
|
||||
groupedSupplies.set(supplyName, currentStock);
|
||||
// Добавляем новый расходник для этого селлера
|
||||
ownerSupplies.set(supplyName, {
|
||||
quantity: currentStock,
|
||||
ownerName: ownerName,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Логирование группировки
|
||||
console.log("📊 Группировка товаров и расходников:", {
|
||||
groupedProductsCount: groupedProducts.size,
|
||||
groupedSuppliesCount: groupedSupplies.size,
|
||||
suppliesByOwnerCount: suppliesByOwner.size,
|
||||
groupedProducts: Array.from(groupedProducts.entries()).map(
|
||||
([name, data]) => ({
|
||||
name,
|
||||
@ -539,10 +590,20 @@ export function FulfillmentWarehouseDashboard() {
|
||||
uniqueSuppliers: [...new Set(data.suppliers)],
|
||||
})
|
||||
),
|
||||
groupedSupplies: Array.from(groupedSupplies.entries()).map(
|
||||
([name, quantity]) => ({
|
||||
name,
|
||||
totalQuantity: quantity,
|
||||
suppliesByOwner: Array.from(suppliesByOwner.entries()).map(
|
||||
([ownerId, ownerSupplies]) => ({
|
||||
ownerId,
|
||||
suppliesCount: ownerSupplies.size,
|
||||
totalQuantity: Array.from(ownerSupplies.values()).reduce(
|
||||
(sum, s) => sum + s.quantity,
|
||||
0
|
||||
),
|
||||
ownerName:
|
||||
Array.from(ownerSupplies.values())[0]?.ownerName || "Unknown",
|
||||
supplies: Array.from(ownerSupplies.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
quantity: data.quantity,
|
||||
})),
|
||||
})
|
||||
),
|
||||
});
|
||||
@ -567,37 +628,56 @@ export function FulfillmentWarehouseDashboard() {
|
||||
const productData = groupedProducts.get(productName)!;
|
||||
const itemProducts = productData.totalQuantity;
|
||||
|
||||
// Ищем соответствующий расходник по названию
|
||||
const matchingSupplyQuantity = groupedSupplies.get(productName) || 0;
|
||||
// ИСПРАВЛЕНО: Ищем расходники конкретного селлера-владельца
|
||||
let itemSuppliesQuantity = 0;
|
||||
let suppliesOwners: string[] = [];
|
||||
|
||||
// Если нет точного совпадения, ищем частичное совпадение
|
||||
let itemSuppliesQuantity = matchingSupplyQuantity;
|
||||
if (itemSuppliesQuantity === 0) {
|
||||
for (const [supplyName, quantity] of groupedSupplies.entries()) {
|
||||
if (
|
||||
supplyName.toLowerCase().includes(productName.toLowerCase()) ||
|
||||
productName.toLowerCase().includes(supplyName.toLowerCase())
|
||||
) {
|
||||
itemSuppliesQuantity = quantity;
|
||||
break;
|
||||
// Получаем реального селлера для этого виртуального партнера
|
||||
const realSeller = sellerPartners[index];
|
||||
|
||||
if (realSeller?.id && suppliesByOwner.has(realSeller.id)) {
|
||||
const sellerSupplies = suppliesByOwner.get(realSeller.id)!;
|
||||
|
||||
// Ищем расходники этого селлера по названию товара
|
||||
const matchingSupply = sellerSupplies.get(productName);
|
||||
|
||||
if (matchingSupply) {
|
||||
itemSuppliesQuantity = matchingSupply.quantity;
|
||||
suppliesOwners = [matchingSupply.ownerName];
|
||||
} else {
|
||||
// Если нет точного совпадения, ищем частичное среди расходников ЭТОГО селлера
|
||||
for (const [supplyName, supplyData] of sellerSupplies.entries()) {
|
||||
if (
|
||||
supplyName
|
||||
.toLowerCase()
|
||||
.includes(productName.toLowerCase()) ||
|
||||
productName.toLowerCase().includes(supplyName.toLowerCase())
|
||||
) {
|
||||
itemSuppliesQuantity = supplyData.quantity;
|
||||
suppliesOwners = [supplyData.ownerName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback к процентному соотношению
|
||||
if (itemSuppliesQuantity === 0) {
|
||||
itemSuppliesQuantity = Math.floor(itemProducts * 0.1);
|
||||
}
|
||||
// Если у этого селлера нет расходников для данного товара - оставляем 0
|
||||
// НЕ используем fallback, так как должны показывать только реальные данные
|
||||
|
||||
console.log(`📦 Товар "${productName}":`, {
|
||||
totalQuantity: itemProducts,
|
||||
suppliersCount: productData.suppliers.length,
|
||||
uniqueSuppliers: [...new Set(productData.suppliers)],
|
||||
matchingSupplyQuantity: matchingSupplyQuantity,
|
||||
finalSuppliesQuantity: itemSuppliesQuantity,
|
||||
usedFallback:
|
||||
matchingSupplyQuantity === 0 && itemSuppliesQuantity > 0,
|
||||
});
|
||||
console.log(
|
||||
`📦 Товар "${productName}" (партнер: ${
|
||||
realSeller?.name || "Unknown"
|
||||
}):`,
|
||||
{
|
||||
totalQuantity: itemProducts,
|
||||
suppliersCount: productData.suppliers.length,
|
||||
uniqueSuppliers: [...new Set(productData.suppliers)],
|
||||
sellerSuppliesQuantity: itemSuppliesQuantity,
|
||||
suppliesOwners: suppliesOwners,
|
||||
sellerId: realSeller?.id,
|
||||
hasSellerSupplies: itemSuppliesQuantity > 0,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: `grouped-${productName}-${itemIndex}`, // Уникальный ID для группированного товара
|
||||
@ -615,6 +695,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
defectsQuantity: 0, // Нет реальных данных о браке
|
||||
sellerSuppliesPlace: `D${index + 1}-${itemIndex + 1}`,
|
||||
sellerSuppliesQuantity: itemSuppliesQuantity, // Суммированное количество расходников (реальные данные)
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах с ПВЗ
|
||||
// Создаем варианты товара
|
||||
@ -634,6 +715,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.4
|
||||
), // Часть от расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-1`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -650,6 +732,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.4
|
||||
), // Часть от расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-2`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -666,6 +749,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
sellerSuppliesQuantity: Math.floor(
|
||||
itemSuppliesQuantity * 0.2
|
||||
), // Оставшаяся часть расходников
|
||||
sellerSuppliesOwners: suppliesOwners, // Владельцы расходников (ИСПРАВЛЕНО)
|
||||
pvzReturnsPlace: `E${index + 1}-${itemIndex + 1}-3`,
|
||||
pvzReturnsQuantity: 0, // Нет реальных данных о возвратах
|
||||
},
|
||||
@ -738,7 +822,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
totalSellerSupplies > 0
|
||||
? Math.floor(
|
||||
(totalSellerSupplies /
|
||||
(mySupplies.reduce(
|
||||
(sellerSupplies.reduce(
|
||||
(sum: number, supply: any) =>
|
||||
sum + (supply.currentStock || 0),
|
||||
0
|
||||
@ -774,7 +858,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
items,
|
||||
};
|
||||
});
|
||||
}, [sellerPartners, allProducts, mySupplies, suppliesReceivedToday]);
|
||||
}, [sellerPartners, allProducts, sellerSupplies, suppliesReceivedToday]);
|
||||
|
||||
// Функции для аватаров магазинов
|
||||
const getInitials = (name: string): string => {
|
||||
@ -1007,9 +1091,14 @@ export function FulfillmentWarehouseDashboard() {
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
// Используем percentChange из GraphQL, если доступно, иначе вычисляем локально
|
||||
const displayPercentChange = percentChange !== undefined && percentChange !== null && !isNaN(percentChange)
|
||||
? percentChange
|
||||
: (current > 0 ? (change / current) * 100 : 0);
|
||||
const displayPercentChange =
|
||||
percentChange !== undefined &&
|
||||
percentChange !== null &&
|
||||
!isNaN(percentChange)
|
||||
? percentChange
|
||||
: current > 0
|
||||
? (change / current) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -1125,7 +1214,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading ||
|
||||
ordersLoading ||
|
||||
productsLoading ||
|
||||
suppliesLoading
|
||||
sellerSuppliesLoading
|
||||
) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
@ -1210,7 +1299,7 @@ export function FulfillmentWarehouseDashboard() {
|
||||
counterpartiesLoading ||
|
||||
ordersLoading ||
|
||||
productsLoading ||
|
||||
suppliesLoading
|
||||
sellerSuppliesLoading
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
@ -1224,7 +1313,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Box}
|
||||
current={warehouseStats.products.current}
|
||||
change={warehouseStats.products.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.products?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.products
|
||||
?.percentChange
|
||||
}
|
||||
description="Готовые к отправке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1232,7 +1324,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Package}
|
||||
current={warehouseStats.goods.current}
|
||||
change={warehouseStats.goods.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.goods?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.goods
|
||||
?.percentChange
|
||||
}
|
||||
description="В обработке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1240,7 +1335,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={AlertTriangle}
|
||||
current={warehouseStats.defects.current}
|
||||
change={warehouseStats.defects.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.defects?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.defects
|
||||
?.percentChange
|
||||
}
|
||||
description="Требует утилизации"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1248,7 +1346,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={RotateCcw}
|
||||
current={warehouseStats.pvzReturns.current}
|
||||
change={warehouseStats.pvzReturns.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.pvzReturns
|
||||
?.percentChange
|
||||
}
|
||||
description="К обработке"
|
||||
/>
|
||||
<StatCard
|
||||
@ -1256,7 +1357,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Wrench}
|
||||
current={warehouseStats.fulfillmentSupplies.current}
|
||||
change={warehouseStats.fulfillmentSupplies.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.fulfillmentSupplies?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats
|
||||
?.fulfillmentSupplies?.percentChange
|
||||
}
|
||||
description="Расходники, этикетки"
|
||||
onClick={() => router.push("/fulfillment-warehouse/supplies")}
|
||||
/>
|
||||
@ -1265,7 +1369,10 @@ export function FulfillmentWarehouseDashboard() {
|
||||
icon={Users}
|
||||
current={warehouseStats.sellerSupplies.current}
|
||||
change={warehouseStats.sellerSupplies.change}
|
||||
percentChange={warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies?.percentChange}
|
||||
percentChange={
|
||||
warehouseStatsData?.fulfillmentWarehouseStats?.sellerSupplies
|
||||
?.percentChange
|
||||
}
|
||||
description="Материалы клиентов"
|
||||
/>
|
||||
</div>
|
||||
@ -1935,11 +2042,43 @@ export function FulfillmentWarehouseDashboard() {
|
||||
|
||||
{/* Расходники селлера */}
|
||||
<div className="grid grid-cols-2 gap-0">
|
||||
<div className="px-1 py-2 text-center text-xs text-white font-medium">
|
||||
{formatNumber(
|
||||
item.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="px-1 py-2 text-center text-xs text-white font-medium cursor-help hover:bg-white/10 rounded">
|
||||
{formatNumber(
|
||||
item.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 glass-card">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium mb-2 text-white">
|
||||
Расходники селлеров:
|
||||
</div>
|
||||
{item.sellerSuppliesOwners &&
|
||||
item.sellerSuppliesOwners.length >
|
||||
0 ? (
|
||||
<div className="space-y-1">
|
||||
{item.sellerSuppliesOwners.map(
|
||||
(owner, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-white/80 flex items-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
|
||||
{owner}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60">
|
||||
Нет данных о владельцах
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="px-1 py-2 text-center text-xs text-white/60">
|
||||
{item.sellerSuppliesPlace || "-"}
|
||||
</div>
|
||||
@ -2065,11 +2204,45 @@ export function FulfillmentWarehouseDashboard() {
|
||||
|
||||
{/* Расходники селлера */}
|
||||
<div className="grid grid-cols-2 gap-0">
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium">
|
||||
{formatNumber(
|
||||
variant.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white font-medium cursor-help hover:bg-white/10 rounded">
|
||||
{formatNumber(
|
||||
variant.sellerSuppliesQuantity
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 glass-card">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium mb-2 text-white">
|
||||
Расходники селлеров:
|
||||
</div>
|
||||
{variant.sellerSuppliesOwners &&
|
||||
variant
|
||||
.sellerSuppliesOwners
|
||||
.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{variant.sellerSuppliesOwners.map(
|
||||
(owner, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-white/80 flex items-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mr-2 flex-shrink-0"></div>
|
||||
{owner}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/60">
|
||||
Нет данных о
|
||||
владельцах
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="px-1 py-1.5 text-center text-[10px] text-white/60">
|
||||
{variant.sellerSuppliesPlace ||
|
||||
"-"}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
601
src/components/supplier-orders/supplier-order-card.tsx
Normal file
601
src/components/supplier-orders/supplier-order-card.tsx
Normal file
@ -0,0 +1,601 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@apollo/client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
SUPPLIER_APPROVE_ORDER,
|
||||
SUPPLIER_REJECT_ORDER,
|
||||
SUPPLIER_SHIP_ORDER,
|
||||
} from "@/graphql/mutations";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Calendar,
|
||||
Package,
|
||||
Truck,
|
||||
User,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Building,
|
||||
Hash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrderCardProps {
|
||||
order: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
inn?: string;
|
||||
};
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
logisticsPartner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
totalPrice: number;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function SupplierOrderCard({ order }: SupplierOrderCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// Мутации для действий поставщика
|
||||
const [supplierApproveOrder, { loading: approving }] = useMutation(
|
||||
SUPPLIER_APPROVE_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierApproveOrder.success) {
|
||||
toast.success(data.supplierApproveOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierApproveOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error approving order:", error);
|
||||
toast.error("Ошибка при одобрении заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [supplierRejectOrder, { loading: rejecting }] = useMutation(
|
||||
SUPPLIER_REJECT_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierRejectOrder.success) {
|
||||
toast.success(data.supplierRejectOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierRejectOrder.message);
|
||||
}
|
||||
setShowRejectModal(false);
|
||||
setRejectReason("");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error rejecting order:", error);
|
||||
toast.error("Ошибка при отклонении заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const [supplierShipOrder, { loading: shipping }] = useMutation(
|
||||
SUPPLIER_SHIP_ORDER,
|
||||
{
|
||||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||||
onCompleted: (data) => {
|
||||
if (data.supplierShipOrder.success) {
|
||||
toast.success(data.supplierShipOrder.message);
|
||||
} else {
|
||||
toast.error(data.supplierShipOrder.message);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error shipping order:", error);
|
||||
toast.error("Ошибка при отправке заказа");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleApproveOrder = async () => {
|
||||
try {
|
||||
await supplierApproveOrder({
|
||||
variables: { id: order.id },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleApproveOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectOrder = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error("Укажите причину отклонения заявки");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await supplierRejectOrder({
|
||||
variables: {
|
||||
id: order.id,
|
||||
reason: rejectReason,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleRejectOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShipOrder = async () => {
|
||||
try {
|
||||
await supplierShipOrder({
|
||||
variables: { id: order.id },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in handleShipOrder:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-400/30">
|
||||
🟡 ОЖИДАЕТ
|
||||
</Badge>
|
||||
);
|
||||
case "SUPPLIER_APPROVED":
|
||||
return (
|
||||
<Badge className="bg-green-500/20 text-green-300 border-green-400/30">
|
||||
🟢 ОДОБРЕНО
|
||||
</Badge>
|
||||
);
|
||||
case "CONFIRMED":
|
||||
case "LOGISTICS_CONFIRMED":
|
||||
return (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-400/30">
|
||||
🔵 В РАБОТЕ
|
||||
</Badge>
|
||||
);
|
||||
case "SHIPPED":
|
||||
case "IN_TRANSIT":
|
||||
return (
|
||||
<Badge className="bg-orange-500/20 text-orange-300 border-orange-400/30">
|
||||
🟠 В ПУТИ
|
||||
</Badge>
|
||||
);
|
||||
case "DELIVERED":
|
||||
return (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-400/30">
|
||||
✅ ДОСТАВЛЕНО
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-white/20 text-white/70 border-white/30">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
const calculateVolume = () => {
|
||||
// Примерный расчет объема - можно улучшить на основе реальных данных о товарах
|
||||
return (order.totalItems * 0.02).toFixed(1); // 0.02 м³ на единицу товара
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="glass-card border-white/10 hover:border-white/20 transition-all">
|
||||
{/* Основная информация - структура согласно правилам */}
|
||||
<div className="p-4">
|
||||
{/* Шапка заявки */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Hash className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white font-semibold">
|
||||
СФ-{order.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-white/70 text-sm">
|
||||
{formatDate(order.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация об участниках */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
{/* Заказчик */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<User className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Заказчик:</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||||
{getInitials(
|
||||
order.organization.name ||
|
||||
order.organization.fullName ||
|
||||
"ОРГ"
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.organization.name || order.organization.fullName}
|
||||
</p>
|
||||
{order.organization.inn && (
|
||||
<p className="text-white/60 text-xs">
|
||||
ИНН: {order.organization.inn}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фулфилмент */}
|
||||
{order.fulfillmentCenter && (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Building className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Фулфилмент:</span>
|
||||
</div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.fulfillmentCenter.name ||
|
||||
order.fulfillmentCenter.fullName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Логистика */}
|
||||
{order.logisticsPartner && (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Truck className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Логистика:</span>
|
||||
</div>
|
||||
<p className="text-white font-medium text-sm">
|
||||
{order.logisticsPartner.name ||
|
||||
order.logisticsPartner.fullName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Краткая информация о заказе */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-4 w-4 text-green-400" />
|
||||
<span className="text-white text-sm">
|
||||
{order.items.length} вид
|
||||
{order.items.length === 1
|
||||
? ""
|
||||
: order.items.length < 5
|
||||
? "а"
|
||||
: "ов"}{" "}
|
||||
товаров
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white text-sm">
|
||||
{order.totalItems} единиц
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white text-sm">
|
||||
📏 {calculateVolume()} м³
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-white font-semibold">
|
||||
💰 {order.totalAmount.toLocaleString()}₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
Подробности
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 ml-1" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Действия для PENDING */}
|
||||
{order.status === "PENDING" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApproveOrder}
|
||||
disabled={approving}
|
||||
className="glass-button bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30"
|
||||
>
|
||||
{approving ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Одобрить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowRejectModal(true)}
|
||||
disabled={rejecting}
|
||||
className="glass-secondary bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||||
>
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
Отклонить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Действие для LOGISTICS_CONFIRMED */}
|
||||
{order.status === "LOGISTICS_CONFIRMED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleShipOrder}
|
||||
disabled={shipping}
|
||||
className="glass-button bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border border-orange-500/30"
|
||||
>
|
||||
{shipping ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Truck className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Отгрузить
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Кнопка связаться всегда доступна */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="glass-secondary text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<MessageCircle className="h-3 w-3 mr-1" />
|
||||
Связаться
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Срок доставки */}
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Доставка:</span>
|
||||
<span className="text-white text-sm">Склад фулфилмента</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-white/60" />
|
||||
<span className="text-white/60 text-sm">Срок:</span>
|
||||
<span className="text-white text-sm">
|
||||
{formatDate(order.deliveryDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Расширенная детализация */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-white/10 p-4">
|
||||
<h4 className="text-white font-semibold mb-3">
|
||||
📋 ДЕТАЛИ ЗАЯВКИ #{order.id.slice(-8)}
|
||||
</h4>
|
||||
|
||||
{/* Товары в заявке */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-white/80 font-medium mb-2">
|
||||
📦 ТОВАРЫ В ЗАЯВКЕ:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{order.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-2 bg-white/5 rounded"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="text-white text-sm">
|
||||
{item.product.name} • {item.quantity} шт • {item.price}
|
||||
₽/шт = {item.totalPrice.toLocaleString()}₽
|
||||
</span>
|
||||
<div className="text-white/60 text-xs">
|
||||
Артикул: {item.product.article}
|
||||
{item.product.category &&
|
||||
` • ${item.product.category.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<span className="text-white font-semibold">
|
||||
Общая стоимость: {order.totalAmount.toLocaleString()}₽
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Логистическая информация */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-white/80 font-medium mb-2">
|
||||
📍 ЛОГИСТИЧЕСКАЯ ИНФОРМАЦИЯ:
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-white/70">
|
||||
• Объем груза: {calculateVolume()} м³
|
||||
</div>
|
||||
<div className="text-white/70">
|
||||
• Предварительная стоимость доставки: ~
|
||||
{Math.round(
|
||||
parseFloat(calculateVolume()) * 3500
|
||||
).toLocaleString()}
|
||||
₽
|
||||
</div>
|
||||
<div className="text-white/70">
|
||||
• Маршрут: Склад поставщика →{" "}
|
||||
{order.fulfillmentCenter?.name || "Фулфилмент-центр"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контактная информация */}
|
||||
<div>
|
||||
<h5 className="text-white/80 font-medium mb-2">📞 КОНТАКТЫ:</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-white/70">
|
||||
• Заказчик:{" "}
|
||||
{order.organization.name || order.organization.fullName}
|
||||
{order.organization.inn &&
|
||||
` (ИНН: ${order.organization.inn})`}
|
||||
</div>
|
||||
{order.fulfillmentCenter && (
|
||||
<div className="text-white/70">
|
||||
• Фулфилмент:{" "}
|
||||
{order.fulfillmentCenter.name ||
|
||||
order.fulfillmentCenter.fullName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Модал отклонения заявки */}
|
||||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||||
<DialogContent className="glass-card border-white/20">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Отклонить заявку</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-white/90 text-sm mb-2 block">
|
||||
Причина отклонения заявки:
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Укажите причину отклонения..."
|
||||
className="glass-input text-white placeholder:text-white/50"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowRejectModal(false)}
|
||||
className="text-white/70 hover:text-white"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRejectOrder}
|
||||
disabled={rejecting || !rejectReason.trim()}
|
||||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||||
>
|
||||
{rejecting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Отклонить заявку
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
178
src/components/supplier-orders/supplier-order-stats.tsx
Normal file
178
src/components/supplier-orders/supplier-order-stats.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
Truck,
|
||||
Package,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrderStatsProps {
|
||||
orders: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SupplierOrderStats({ orders }: SupplierOrderStatsProps) {
|
||||
const stats = useMemo(() => {
|
||||
const pending = orders.filter((order) => order.status === "PENDING").length;
|
||||
const approved = orders.filter(
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
).length;
|
||||
const inProgress = orders.filter((order) =>
|
||||
["CONFIRMED", "LOGISTICS_CONFIRMED"].includes(order.status)
|
||||
).length;
|
||||
const shipping = orders.filter((order) =>
|
||||
["SHIPPED", "IN_TRANSIT"].includes(order.status)
|
||||
).length;
|
||||
const completed = orders.filter(
|
||||
(order) => order.status === "DELIVERED"
|
||||
).length;
|
||||
|
||||
const totalRevenue = orders
|
||||
.filter((order) => order.status === "DELIVERED")
|
||||
.reduce((sum, order) => sum + order.totalAmount, 0);
|
||||
|
||||
const totalItems = orders.reduce((sum, order) => sum + order.totalItems, 0);
|
||||
|
||||
// Заявки за сегодня
|
||||
const today = new Date().toDateString();
|
||||
const todayOrders = orders.filter(
|
||||
(order) => new Date(order.createdAt).toDateString() === today
|
||||
).length;
|
||||
|
||||
return {
|
||||
pending,
|
||||
approved,
|
||||
inProgress,
|
||||
shipping,
|
||||
completed,
|
||||
totalRevenue,
|
||||
totalItems,
|
||||
todayOrders,
|
||||
total: orders.length,
|
||||
};
|
||||
}, [orders]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{/* Ожидают одобрения */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-yellow-500/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Ожидают одобрения</p>
|
||||
<p className="text-xl font-bold text-white">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Одобренные */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Одобренные</p>
|
||||
<p className="text-xl font-bold text-white">{stats.approved}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* В работе */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Settings className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">В работе</p>
|
||||
<p className="text-xl font-bold text-white">{stats.inProgress}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Готово к отправке / В пути */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-orange-500/20 rounded-lg">
|
||||
<Truck className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Отгрузка/В пути</p>
|
||||
<p className="text-xl font-bold text-white">{stats.shipping}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Доставлено */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-emerald-500/20 rounded-lg">
|
||||
<Package className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Доставлено</p>
|
||||
<p className="text-xl font-bold text-white">{stats.completed}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Заявки за сегодня */}
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">За сегодня</p>
|
||||
<p className="text-xl font-bold text-white">{stats.todayOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Общая выручка */}
|
||||
<Card className="glass-card border-white/10 p-4 md:col-span-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Выручка (завершенные)</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{stats.totalRevenue.toLocaleString()}₽
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Всего товаров */}
|
||||
<Card className="glass-card border-white/10 p-4 md:col-span-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 text-sm">Всего товаров в заявках</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{stats.totalItems.toLocaleString()} шт.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
210
src/components/supplier-orders/supplier-orders-search.tsx
Normal file
210
src/components/supplier-orders/supplier-orders-search.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Package,
|
||||
Building,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplierOrdersSearchProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
priceRange: { min: string; max: string };
|
||||
onPriceRangeChange: (range: { min: string; max: string }) => void;
|
||||
dateFilter: string;
|
||||
onDateFilterChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SupplierOrdersSearch({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
priceRange,
|
||||
onPriceRangeChange,
|
||||
dateFilter,
|
||||
onDateFilterChange,
|
||||
}: SupplierOrdersSearchProps) {
|
||||
const hasActiveFilters = priceRange.min || priceRange.max || dateFilter;
|
||||
|
||||
const clearFilters = () => {
|
||||
onPriceRangeChange({ min: "", max: "" });
|
||||
onDateFilterChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="glass-card border-white/10 p-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Поисковая строка */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/60" />
|
||||
<Input
|
||||
placeholder="Поиск по номеру заявки, заказчику, товарам, ИНН..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="glass-input text-white placeholder:text-white/50 pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`glass-secondary border-white/20 ${
|
||||
hasActiveFilters ? "border-blue-400/50 bg-blue-500/10" : ""
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Фильтры
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 bg-blue-500/20 text-blue-300 px-2 py-1 rounded text-xs">
|
||||
Активны
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="glass-card border-white/20 w-80">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-white font-semibold">Фильтры поиска</h4>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Очистить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Фильтр по дате */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-white/90 flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Период создания заявки
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => onDateFilterChange(e.target.value)}
|
||||
className="glass-input text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по стоимости */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-white/90 flex items-center">
|
||||
<DollarSign className="h-4 w-4 mr-2" />
|
||||
Диапазон стоимости (₽)
|
||||
</Label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="От"
|
||||
value={priceRange.min}
|
||||
onChange={(e) =>
|
||||
onPriceRangeChange({
|
||||
...priceRange,
|
||||
min: e.target.value,
|
||||
})
|
||||
}
|
||||
className="glass-input text-white placeholder:text-white/50 w-24"
|
||||
/>
|
||||
<span className="text-white/60 self-center">—</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="До"
|
||||
value={priceRange.max}
|
||||
onChange={(e) =>
|
||||
onPriceRangeChange({
|
||||
...priceRange,
|
||||
max: e.target.value,
|
||||
})
|
||||
}
|
||||
className="glass-input text-white placeholder:text-white/50 w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о поиске */}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-white/60 text-xs">
|
||||
💡 <strong>Поиск работает по:</strong>
|
||||
</p>
|
||||
<ul className="text-white/60 text-xs mt-1 space-y-1">
|
||||
<li>• Номеру заявки (СФ-2024-XXX)</li>
|
||||
<li>• Названию заказчика</li>
|
||||
<li>• Названию товаров</li>
|
||||
<li>• ИНН заказчика</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Быстрые фильтры */}
|
||||
<div className="hidden lg:flex items-center space-x-2 text-white/60 text-sm">
|
||||
<span>Быстро:</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onDateFilterChange(new Date().toISOString().split("T")[0])
|
||||
}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
Сегодня
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
onDateFilterChange(weekAgo.toISOString().split("T")[0]);
|
||||
}}
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
Неделя
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные фильтры */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<span className="text-white/60">Активные фильтры:</span>
|
||||
{dateFilter && (
|
||||
<span className="bg-blue-500/20 text-blue-300 px-2 py-1 rounded border border-blue-400/30">
|
||||
📅 {new Date(dateFilter).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
)}
|
||||
{(priceRange.min || priceRange.max) && (
|
||||
<span className="bg-green-500/20 text-green-300 px-2 py-1 rounded border border-green-400/30">
|
||||
💰 {priceRange.min || "0"}₽ — {priceRange.max || "∞"}₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
309
src/components/supplier-orders/supplier-orders-tabs.tsx
Normal file
309
src/components/supplier-orders/supplier-orders-tabs.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { SupplierOrderCard } from "./supplier-order-card";
|
||||
import { SupplierOrderStats } from "./supplier-order-stats";
|
||||
import { SupplierOrdersSearch } from "./supplier-orders-search";
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
Truck,
|
||||
Package,
|
||||
Calendar,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
partnerId: string;
|
||||
deliveryDate: string;
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
inn?: string;
|
||||
};
|
||||
partner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
inn?: string;
|
||||
address?: string;
|
||||
phones?: string[];
|
||||
emails?: string[];
|
||||
};
|
||||
fulfillmentCenter?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
logisticsPartner?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
totalPrice: number;
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
category?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export function SupplierOrdersTabs() {
|
||||
const { user } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState("new");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [dateFilter, setDateFilter] = useState("");
|
||||
const [priceRange, setPriceRange] = useState({ min: "", max: "" });
|
||||
|
||||
// Загружаем заказы поставок
|
||||
const { data, loading, error } = useQuery(GET_SUPPLY_ORDERS, {
|
||||
fetchPolicy: "cache-and-network",
|
||||
});
|
||||
|
||||
// Фильтруем заказы где текущая организация является поставщиком
|
||||
const supplierOrders: SupplyOrder[] = useMemo(() => {
|
||||
return (data?.supplyOrders || []).filter(
|
||||
(order: SupplyOrder) => order.partnerId === user?.organization?.id
|
||||
);
|
||||
}, [data?.supplyOrders, user?.organization?.id]);
|
||||
|
||||
// Фильтрация заказов по поисковому запросу
|
||||
const filteredOrders = useMemo(() => {
|
||||
let filtered = supplierOrders;
|
||||
|
||||
// Поиск по номеру заявки, заказчику, товарам, ИНН
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(order) =>
|
||||
order.id.toLowerCase().includes(query) ||
|
||||
(order.organization.name || "").toLowerCase().includes(query) ||
|
||||
(order.organization.fullName || "").toLowerCase().includes(query) ||
|
||||
(order.organization.inn || "").toLowerCase().includes(query) ||
|
||||
order.items.some((item) =>
|
||||
item.product.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Фильтр по диапазону цены
|
||||
if (priceRange.min || priceRange.max) {
|
||||
filtered = filtered.filter((order) => {
|
||||
if (priceRange.min && order.totalAmount < parseFloat(priceRange.min))
|
||||
return false;
|
||||
if (priceRange.max && order.totalAmount > parseFloat(priceRange.max))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [supplierOrders, searchQuery, priceRange]);
|
||||
|
||||
// Разделение заказов по статусам согласно правилам
|
||||
const ordersByStatus = useMemo(() => {
|
||||
return {
|
||||
new: filteredOrders.filter((order) => order.status === "PENDING"),
|
||||
approved: filteredOrders.filter(
|
||||
(order) => order.status === "SUPPLIER_APPROVED"
|
||||
),
|
||||
inProgress: filteredOrders.filter((order) =>
|
||||
["CONFIRMED", "LOGISTICS_CONFIRMED"].includes(order.status)
|
||||
),
|
||||
shipping: filteredOrders.filter((order) =>
|
||||
["SHIPPED", "IN_TRANSIT"].includes(order.status)
|
||||
),
|
||||
completed: filteredOrders.filter((order) => order.status === "DELIVERED"),
|
||||
all: filteredOrders,
|
||||
};
|
||||
}, [filteredOrders]);
|
||||
|
||||
const getTabBadgeCount = (tabKey: string) => {
|
||||
return ordersByStatus[tabKey as keyof typeof ordersByStatus]?.length || 0;
|
||||
};
|
||||
|
||||
const getCurrentOrders = () => {
|
||||
return ordersByStatus[activeTab as keyof typeof ordersByStatus] || [];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-white/60">Загрузка заявок...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">
|
||||
Ошибка загрузки заявок: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Статистика - Модуль 2 согласно правилам */}
|
||||
<SupplierOrderStats orders={supplierOrders} />
|
||||
|
||||
{/* Блок табов - отдельный блок согласно visual-design-rules.md */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-transparent p-0 space-x-2">
|
||||
{/* Уровень 2: Фильтрация по статусам */}
|
||||
<TabsTrigger
|
||||
value="new"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-0 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
Новые
|
||||
{getTabBadgeCount("new") > 0 && (
|
||||
<Badge className="ml-2 bg-red-500/20 text-red-300 border-red-400/30">
|
||||
{getTabBadgeCount("new")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="approved"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Одобренные
|
||||
{getTabBadgeCount("approved") > 0 && (
|
||||
<Badge className="ml-2 bg-green-500/20 text-green-300 border-green-400/30">
|
||||
{getTabBadgeCount("approved")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="inProgress"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />В работе
|
||||
{getTabBadgeCount("inProgress") > 0 && (
|
||||
<Badge className="ml-2 bg-blue-500/20 text-blue-300 border-blue-400/30">
|
||||
{getTabBadgeCount("inProgress")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="shipping"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Truck className="h-4 w-4 mr-2" />
|
||||
Отгрузка
|
||||
{getTabBadgeCount("shipping") > 0 && (
|
||||
<Badge className="ml-2 bg-orange-500/20 text-orange-300 border-orange-400/30">
|
||||
{getTabBadgeCount("shipping")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="completed"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Завершенные
|
||||
{getTabBadgeCount("completed") > 0 && (
|
||||
<Badge className="ml-2 bg-emerald-500/20 text-emerald-300 border-emerald-400/30">
|
||||
{getTabBadgeCount("completed")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="h-9 bg-white/8 border-white/20 rounded-lg font-medium ml-4 data-[state=active]:bg-white/15 data-[state=active]:text-white"
|
||||
>
|
||||
Все заявки
|
||||
{getTabBadgeCount("all") > 0 && (
|
||||
<Badge className="ml-2 bg-white/20 text-white/70 border-white/30">
|
||||
{getTabBadgeCount("all")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Поиск и фильтры */}
|
||||
<SupplierOrdersSearch
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
priceRange={priceRange}
|
||||
onPriceRangeChange={setPriceRange}
|
||||
dateFilter={dateFilter}
|
||||
onDateFilterChange={setDateFilter}
|
||||
/>
|
||||
|
||||
{/* Рабочее пространство - отдельный блок */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl">
|
||||
<div className="p-6">
|
||||
{getCurrentOrders().length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
{activeTab === "new" ? "Нет новых заявок" : "Заявки не найдены"}
|
||||
</h3>
|
||||
<p className="text-white/60">
|
||||
{activeTab === "new"
|
||||
? "Новые заявки от заказчиков будут отображаться здесь"
|
||||
: "Попробуйте изменить фильтры поиска"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{getCurrentOrders().map((order) => (
|
||||
<SupplierOrderCard key={order.id} order={order} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -77,6 +78,7 @@ interface SelectedConsumable {
|
||||
|
||||
export function CreateConsumablesSupplyPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const [selectedSupplier, setSelectedSupplier] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
@ -88,6 +90,8 @@ export function CreateConsumablesSupplyPage() {
|
||||
const [deliveryDate, setDeliveryDate] = useState("");
|
||||
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
const [selectedLogistics, setSelectedLogistics] =
|
||||
useState<ConsumableSupplier | null>(null);
|
||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
||||
|
||||
// Загружаем контрагентов-поставщиков расходников
|
||||
@ -117,6 +121,11 @@ export function CreateConsumablesSupplyPage() {
|
||||
counterpartiesData?.myCounterparties || []
|
||||
).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT");
|
||||
|
||||
// Фильтруем логистические компании
|
||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: ConsumableSupplier) => org.type === "LOGIST"
|
||||
);
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = consumableSuppliers.filter(
|
||||
(supplier: ConsumableSupplier) =>
|
||||
@ -218,19 +227,82 @@ export function CreateConsumablesSupplyPage() {
|
||||
selectedConsumables.length === 0 ||
|
||||
!deliveryDate
|
||||
) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
toast.error(
|
||||
"Заполните все обязательные поля: поставщик, расходники и дата доставки"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Для селлеров требуется выбор фулфилмент-центра
|
||||
// TODO: Добавить проверку типа текущей организации
|
||||
if (!selectedFulfillmentCenter) {
|
||||
toast.error("Выберите фулфилмент-центр для доставки");
|
||||
return;
|
||||
}
|
||||
|
||||
// Логистика опциональна - может выбрать селлер или оставить фулфилменту
|
||||
if (selectedLogistics && !selectedLogistics.id) {
|
||||
toast.error("Некорректно выбрана логистическая компания");
|
||||
return;
|
||||
}
|
||||
|
||||
// Дополнительные проверки
|
||||
if (!selectedFulfillmentCenter.id) {
|
||||
toast.error("ID фулфилмент-центра не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSupplier.id) {
|
||||
toast.error("ID поставщика не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedConsumables.length === 0) {
|
||||
toast.error("Не выбраны расходники");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем дату
|
||||
const deliveryDateObj = new Date(deliveryDate);
|
||||
if (isNaN(deliveryDateObj.getTime())) {
|
||||
toast.error("Некорректная дата поставки");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSupply(true);
|
||||
|
||||
// 🔍 ОТЛАДКА: проверяем текущего пользователя
|
||||
console.log("👤 Текущий пользователь:", {
|
||||
userId: user?.id,
|
||||
phone: user?.phone,
|
||||
organizationId: user?.organization?.id,
|
||||
organizationType: user?.organization?.type,
|
||||
organizationName:
|
||||
user?.organization?.name || user?.organization?.fullName,
|
||||
});
|
||||
|
||||
console.log("🚀 Создаем поставку с данными:", {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
logisticsPartnerId: selectedLogistics?.id,
|
||||
hasLogistics: !!selectedLogistics?.id,
|
||||
consumableType: "SELLER_CONSUMABLES",
|
||||
itemsCount: selectedConsumables.length,
|
||||
mutationInput: {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
...(selectedLogistics?.id
|
||||
? { logisticsPartnerId: selectedLogistics.id }
|
||||
: {}),
|
||||
consumableType: "SELLER_CONSUMABLES",
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
quantity: consumable.selectedQuantity,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await createSupplyOrder({
|
||||
variables: {
|
||||
@ -238,6 +310,12 @@ export function CreateConsumablesSupplyPage() {
|
||||
partnerId: selectedSupplier.id,
|
||||
deliveryDate: deliveryDate,
|
||||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||||
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
|
||||
...(selectedLogistics?.id
|
||||
? { logisticsPartnerId: selectedLogistics.id }
|
||||
: {}),
|
||||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||||
consumableType: "SELLER_CONSUMABLES", // Расходники селлеров
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
quantity: consumable.selectedQuantity,
|
||||
@ -270,7 +348,21 @@ export function CreateConsumablesSupplyPage() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating consumables supply:", error);
|
||||
toast.error("Ошибка при создании поставки расходников");
|
||||
|
||||
// Детальная диагностика ошибки
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
|
||||
// Показываем конкретную ошибку пользователю
|
||||
toast.error(`Ошибка: ${error.message}`);
|
||||
} else {
|
||||
console.error("Unknown error:", error);
|
||||
toast.error("Ошибка при создании поставки расходников");
|
||||
}
|
||||
} finally {
|
||||
setIsCreatingSupply(false);
|
||||
}
|
||||
@ -764,7 +856,7 @@ export function CreateConsumablesSupplyPage() {
|
||||
);
|
||||
setSelectedFulfillmentCenter(center || null);
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
@ -782,8 +874,73 @@ export function CreateConsumablesSupplyPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg
|
||||
className="w-4 h-4 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* БЛОК ВЫБОРА ЛОГИСТИЧЕСКОЙ КОМПАНИИ */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">
|
||||
Логистическая компания:
|
||||
<span className="text-white/40 ml-1">(опционально)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLogistics?.id || ""}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value;
|
||||
const logistics = logisticsPartners.find(
|
||||
(p) => p.id === logisticsId
|
||||
);
|
||||
setSelectedLogistics(logistics || null);
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Выберите логистику или оставьте фулфилменту
|
||||
</option>
|
||||
{logisticsPartners.map((partner) => (
|
||||
<option
|
||||
key={partner.id}
|
||||
value={partner.id}
|
||||
className="bg-gray-800 text-white"
|
||||
>
|
||||
{partner.name || partner.fullName || "Логистика"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg
|
||||
className="w-4 h-4 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">
|
||||
Дата поставки:
|
||||
|
@ -42,7 +42,15 @@ interface SupplyOrderItem {
|
||||
interface SupplyOrder {
|
||||
id: string;
|
||||
deliveryDate: string;
|
||||
status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED";
|
||||
status:
|
||||
| "PENDING"
|
||||
| "SUPPLIER_APPROVED"
|
||||
| "CONFIRMED"
|
||||
| "LOGISTICS_CONFIRMED"
|
||||
| "SHIPPED"
|
||||
| "IN_TRANSIT"
|
||||
| "DELIVERED"
|
||||
| "CANCELLED";
|
||||
totalAmount: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
@ -102,10 +110,22 @@ export function SellerSupplyOrdersTab() {
|
||||
label: "Ожидает одобрения",
|
||||
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
|
||||
},
|
||||
SUPPLIER_APPROVED: {
|
||||
label: "Одобрена поставщиком",
|
||||
color: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
|
||||
},
|
||||
CONFIRMED: {
|
||||
label: "Одобрена",
|
||||
label: "Подтверждена",
|
||||
color: "bg-green-500/20 text-green-300 border-green-500/30",
|
||||
},
|
||||
LOGISTICS_CONFIRMED: {
|
||||
label: "Готова к отправке",
|
||||
color: "bg-teal-500/20 text-teal-300 border-teal-500/30",
|
||||
},
|
||||
SHIPPED: {
|
||||
label: "Отправлена",
|
||||
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
|
||||
},
|
||||
IN_TRANSIT: {
|
||||
label: "В пути",
|
||||
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
@ -119,7 +139,16 @@ export function SellerSupplyOrdersTab() {
|
||||
color: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
},
|
||||
};
|
||||
const { label, color } = statusMap[status];
|
||||
const config = statusMap[status as keyof typeof statusMap];
|
||||
if (!config) {
|
||||
// Fallback для неизвестных статусов
|
||||
return (
|
||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
const { label, color } = config;
|
||||
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
|
||||
};
|
||||
|
||||
|
@ -42,7 +42,27 @@ export function SuppliesDashboard() {
|
||||
});
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount;
|
||||
const hasPendingItems = pendingCount && pendingCount.total > 0;
|
||||
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
|
||||
const hasPendingItems = (() => {
|
||||
if (!pendingCount) return false;
|
||||
|
||||
switch (user?.organization?.type) {
|
||||
case "SELLER":
|
||||
// Селлеры не получают уведомления о поставках - только отслеживают статус
|
||||
return false;
|
||||
case "WHOLESALE":
|
||||
// Поставщики видят только входящие заказы, не заявки на партнерство
|
||||
return pendingCount.incomingSupplierOrders > 0;
|
||||
case "FULFILLMENT":
|
||||
// Фулфилмент видит только поставки к обработке, не заявки на партнерство
|
||||
return pendingCount.supplyOrders > 0;
|
||||
case "LOGIST":
|
||||
// Логистика видит только логистические заявки, не заявки на партнерство
|
||||
return pendingCount.logisticsOrders > 0;
|
||||
default:
|
||||
return pendingCount.total > 0;
|
||||
}
|
||||
})();
|
||||
|
||||
// Автоматически открываем нужную вкладку при загрузке
|
||||
useEffect(() => {
|
||||
@ -69,32 +89,33 @@ export function SuppliesDashboard() {
|
||||
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
У вас есть {pendingCount.total} элемент
|
||||
{pendingCount.total > 1
|
||||
? pendingCount.total < 5
|
||||
? "а"
|
||||
: "ов"
|
||||
: ""}
|
||||
, требующ{pendingCount.total > 1 ? "их" : "ий"} одобрения:
|
||||
{pendingCount.supplyOrders > 0 &&
|
||||
` ${pendingCount.supplyOrders} заказ${
|
||||
pendingCount.supplyOrders > 1
|
||||
? pendingCount.supplyOrders < 5
|
||||
? "а"
|
||||
: "ов"
|
||||
: ""
|
||||
} поставок`}
|
||||
{pendingCount.incomingRequests > 0 &&
|
||||
pendingCount.supplyOrders > 0 &&
|
||||
", "}
|
||||
{pendingCount.incomingRequests > 0 &&
|
||||
` ${pendingCount.incomingRequests} заявк${
|
||||
pendingCount.incomingRequests > 1
|
||||
? pendingCount.incomingRequests < 5
|
||||
? "и"
|
||||
: ""
|
||||
: "а"
|
||||
} на партнерство`}
|
||||
{(() => {
|
||||
switch (user?.organization?.type) {
|
||||
case "WHOLESALE":
|
||||
const orders = pendingCount.incomingSupplierOrders || 0;
|
||||
return `У вас ${orders} входящ${
|
||||
orders > 1 ? (orders < 5 ? "их" : "их") : "ий"
|
||||
} заказ${
|
||||
orders > 1 ? (orders < 5 ? "а" : "ов") : ""
|
||||
} от клиентов, ожидающ${
|
||||
orders > 1 ? "их" : "ий"
|
||||
} подтверждения`;
|
||||
case "FULFILLMENT":
|
||||
const supplies = pendingCount.supplyOrders || 0;
|
||||
return `У вас ${supplies} поставк${
|
||||
supplies > 1 ? (supplies < 5 ? "и" : "ов") : "а"
|
||||
} к обработке`;
|
||||
case "LOGIST":
|
||||
const logistics = pendingCount.logisticsOrders || 0;
|
||||
return `У вас ${logistics} логистическ${
|
||||
logistics > 1 ? (logistics < 5 ? "их" : "их") : "ая"
|
||||
} заявк${
|
||||
logistics > 1 ? (logistics < 5 ? "и" : "и") : "а"
|
||||
} к подтверждению`;
|
||||
default:
|
||||
return `У вас есть элементы, требующие внимания`;
|
||||
}
|
||||
})()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
26
src/components/ui/textarea.tsx
Normal file
26
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
// Interface extends parent interface - can be empty but needs a member for type safety
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
Reference in New Issue
Block a user