feat(产品管理): 卡片跳转 + 时间字段 + 状态筛选多选
- 点击产品卡片跳转 /admin/tasks?sn=身份证,与全局概览看板一致 - 卡片新增「当前人时间」(滞留时长) 与「生产总天数」(自创建) - 状态筛选 tabs 去掉「已入库」(仓库已显示为已完成),改为多选 - 卡片内编辑/删除/打印按钮 stopPropagation 避免误触跳转 - 清理未使用的 import
This commit is contained in:
@ -1,32 +1,32 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
|
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
|
||||||
Package, Hash, Tag, MapPin, Clock, Pencil, Trash2, Save, AlertTriangle,
|
Package, Hash, Tag, MapPin, Clock, Timer, CalendarDays,
|
||||||
|
Pencil, Trash2, Save, AlertTriangle,
|
||||||
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
|
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import api from "../../services/api";
|
import api from "../../services/api";
|
||||||
import type { ProductResponse } from "../../types/admin";
|
import type { ProductResponse } from "../../types/admin";
|
||||||
import CreateProductDialog from "./CreateProductDialog";
|
import CreateProductDialog from "./CreateProductDialog";
|
||||||
import {
|
import {
|
||||||
getLabelPreview, executePrint, type LabelPreviewRequest,
|
getLabelPreview, executePrint,
|
||||||
} from "../../services/printApi";
|
} from "../../services/printApi";
|
||||||
import { useToast } from "../../components/ui/Toast";
|
import { useToast } from "../../components/ui/Toast";
|
||||||
import { getStatusConfig } from "../../constants/task";
|
|
||||||
|
|
||||||
const QR_BASE = "/api/v1/products/qrcode";
|
const QR_BASE = "/api/v1/products/qrcode";
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ key: "", label: "全部" },
|
|
||||||
{ key: "PENDING", label: "待接收" },
|
{ key: "PENDING", label: "待接收" },
|
||||||
{ key: "WIP", label: "进行中" },
|
{ key: "WIP", label: "进行中" },
|
||||||
{ key: "COMPLETED", label: "已完成" },
|
{ key: "COMPLETED", label: "已完成" },
|
||||||
{ key: "ARCHIVED", label: "已入库" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
|
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
|
||||||
|
|
||||||
export default function AdminProductsPage() {
|
export default function AdminProductsPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -34,7 +34,7 @@ export default function AdminProductsPage() {
|
|||||||
|
|
||||||
// 搜索 & 筛选 & 分组
|
// 搜索 & 筛选 & 分组
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
|
||||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@ -73,13 +73,13 @@ export default function AdminProductsPage() {
|
|||||||
|
|
||||||
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
|
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
|
||||||
|
|
||||||
// ---- 分组 + 状态过滤 ----
|
// ---- 分组 + 状态过滤(多选)----
|
||||||
const productGroups = useMemo<ProductGroup[]>(() => {
|
const productGroups = useMemo<ProductGroup[]>(() => {
|
||||||
const map = new Map<string, ProductResponse[]>();
|
const map = new Map<string, ProductResponse[]>();
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
if (statusFilter) {
|
if (statusFilters.size > 0) {
|
||||||
const s = (p.macro_status || p.status).toUpperCase();
|
const s = (p.macro_status || p.status).toUpperCase();
|
||||||
if (s !== statusFilter) continue;
|
if (!statusFilters.has(s)) continue;
|
||||||
}
|
}
|
||||||
const key = groupBy === "device"
|
const key = groupBy === "device"
|
||||||
? (p.material_name || p.material_id || "未命名设备")
|
? (p.material_name || p.material_id || "未命名设备")
|
||||||
@ -91,7 +91,15 @@ export default function AdminProductsPage() {
|
|||||||
groupKey, products: prods,
|
groupKey, products: prods,
|
||||||
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
|
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
|
||||||
}));
|
}));
|
||||||
}, [products, statusFilter, groupBy]);
|
}, [products, statusFilters, groupBy]);
|
||||||
|
|
||||||
|
function toggleStatusFilter(key: string) {
|
||||||
|
setStatusFilters(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 默认全部展开:productGroups 变化时自动展开所有面板
|
// 🔧 默认全部展开:productGroups 变化时自动展开所有面板
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -161,12 +169,19 @@ export default function AdminProductsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||||
{STATUS_TABS.map(tab => (
|
<button onClick={() => setStatusFilters(new Set())}
|
||||||
<button key={tab.key} onClick={() => setStatusFilter(tab.key)}
|
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilters.size === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||||
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilter === tab.key ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
全部
|
||||||
|
</button>
|
||||||
|
{STATUS_TABS.map(tab => {
|
||||||
|
const active = statusFilters.has(tab.key);
|
||||||
|
return (
|
||||||
|
<button key={tab.key} onClick={() => toggleStatusFilter(tab.key)}
|
||||||
|
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${active ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -210,10 +225,10 @@ export default function AdminProductsPage() {
|
|||||||
<div className="border-t border-gray-100 px-5 py-4">
|
<div className="border-t border-gray-100 px-5 py-4">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{group.products.map(p => (
|
{group.products.map(p => (
|
||||||
<div key={p.id} className="group relative flex flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
|
<div key={p.id} onClick={() => navigate(`/admin/tasks?sn=${p.serial_number}`)} className="group relative flex cursor-pointer flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
|
||||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
|
||||||
<button onClick={() => openEdit(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
|
<button onClick={(e) => { e.stopPropagation(); openEdit(p); }} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
|
||||||
<button onClick={() => confirmDelete(p)} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>
|
<button onClick={(e) => { e.stopPropagation(); confirmDelete(p); }} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center px-4 pt-5 pb-3">
|
<div className="flex flex-col items-center px-4 pt-5 pb-3">
|
||||||
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
|
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
|
||||||
@ -225,10 +240,12 @@ export default function AdminProductsPage() {
|
|||||||
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
|
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
|
||||||
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
|
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
|
||||||
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
|
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
|
||||||
|
<InfoRow icon={Timer} label="当前人时间" value={formatDuration(p.active_duration_hours)} />
|
||||||
|
<InfoRow icon={CalendarDays} label="生产总天数" value={formatProductionDays(p.created_at)} />
|
||||||
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
|
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-gray-50 px-4 py-3">
|
<div className="border-t border-gray-50 px-4 py-3">
|
||||||
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
|
<button onClick={(e) => { e.stopPropagation(); handleOpenPrint(p); }} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
|
||||||
<Printer className="h-3.5 w-3.5" />打印标签
|
<Printer className="h-3.5 w-3.5" />打印标签
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -294,3 +311,17 @@ export default function AdminProductsPage() {
|
|||||||
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||||
return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
|
return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 滞留时长格式化:小时 → 天/小时/分钟 */
|
||||||
|
function formatDuration(hours: number | null): string {
|
||||||
|
if (hours == null) return "—";
|
||||||
|
if (hours >= 24) return `${Math.round(hours / 24)}天`;
|
||||||
|
if (hours >= 1) return `${Math.round(hours)}小时`;
|
||||||
|
return `${Math.max(1, Math.round(hours * 60))}分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生产总天数:从产品创建(created_at)到现在,向上取整,最少 1 天 */
|
||||||
|
function formatProductionDays(createdAt: string): string {
|
||||||
|
const days = Math.max(1, Math.ceil((Date.now() - new Date(createdAt).getTime()) / 86400000));
|
||||||
|
return `${days} 天`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user