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

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

View File

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