602 lines
20 KiB
TypeScript
602 lines
20 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|