Compare commits
16 Commits
620df5ce11
...
d9a70793f7
| Author | SHA1 | Date | |
|---|---|---|---|
| d9a70793f7 | |||
| 90f5718e68 | |||
| 1e6adeb576 | |||
| 88f02cbdcf | |||
| 1bb93e0a5f | |||
| ddb1b4364b | |||
| 7cfaf4b04b | |||
| d92fcc4d4a | |||
| 7e00683a21 | |||
| 645c594ecd | |||
| 91f10cf818 | |||
| 0bd0fbc2ec | |||
| 19c7c7bbf1 | |||
| 24c124cd30 | |||
| d007c7b843 | |||
| 90d615cbb0 |
@ -9,6 +9,7 @@ from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_rejected_tasks, RejectedTask,
|
||||
get_people_workload, PersonWorkload,
|
||||
get_people_history, PersonHistoryRecord,
|
||||
search_product_messages, ProductMessageList,
|
||||
@ -56,6 +57,19 @@ async def completed_tasks(
|
||||
return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/rejected-tasks", response_model=list[RejectedTask])
|
||||
async def rejected_tasks(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/people-workload", response_model=list[PersonWorkload])
|
||||
async def people_workload(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@ -61,6 +61,8 @@ class ProductResponse(BaseModel):
|
||||
latest_record_has_images: bool = False
|
||||
latest_record_assignee_id: str | None = None # 🔧 最新记录操作人(消除并发张冠李戴)
|
||||
latest_record_assignee_name: str | None = None
|
||||
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
|
||||
active_duration_hours: float | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@ -48,6 +48,21 @@ class CompletedTask(BaseModel):
|
||||
completed_at: str # 完成时间 ISO
|
||||
|
||||
|
||||
class RejectedTask(BaseModel):
|
||||
task_id: str
|
||||
kind: str = "rejected" # "rejected"(已驳回) | "rework"(返工任务)
|
||||
task_name: str # 工序
|
||||
product_sn: str # 16位HEX身份证
|
||||
external_serial: str | None # 业务序列号
|
||||
material_name: str # 产品名称(物料名称)
|
||||
spec_model: str # 规格型号
|
||||
rejected_by: str = "—" # 驳回人中文姓名(rework 可能无)
|
||||
rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工)
|
||||
reject_reason: str | None # 驳回原因
|
||||
status: str | None = None # 当前状态(rework 任务用:PENDING/WIP/COMPLETED)
|
||||
rejected_at: str | None # 驳回时间 ISO(BEIJING_TZ)
|
||||
|
||||
|
||||
class PersonDevice(BaseModel):
|
||||
product_id: str
|
||||
serial_number: str # 16位HEX身份证
|
||||
@ -283,6 +298,173 @@ async def get_completed_tasks(
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 被驳回任务明细(品质驳回下钻 — 按时段过滤)
|
||||
# ============================================================
|
||||
|
||||
async def get_rejected_tasks(
|
||||
db: AsyncSession,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[RejectedTask]:
|
||||
"""查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。
|
||||
|
||||
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
|
||||
- kind="rejected":被驳回任务,按 completed_at 时间过滤
|
||||
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间
|
||||
|
||||
返工负责人追溯逻辑与 task_service.reject_task 一致:
|
||||
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_REJECTED
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
|
||||
def _to_bj_iso(dt) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt.isoformat()
|
||||
|
||||
# ── 1. 已驳回任务(按时间过滤)──
|
||||
stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.status == TASK_STATUS_REJECTED)
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(Task.completed_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(Task.completed_at <= until)
|
||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||
rejected_rows = (await db.execute(stmt)).all()
|
||||
|
||||
# ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)──
|
||||
rework_stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rework_rows = (await db.execute(rework_stmt)).all()
|
||||
|
||||
# 合并:rejected 在前,rework 在后
|
||||
all_rows: list[tuple[str, object]] = [
|
||||
*[("rejected", t) for t in rejected_rows],
|
||||
*[("rework", t) for t in rework_rows],
|
||||
]
|
||||
|
||||
# ── 已驳回任务:批量取驳回人 / 最早 create log / 父任务负责人 ──
|
||||
rejected_ids = [t.id for t, *_ in rejected_rows]
|
||||
reject_op: dict = {}
|
||||
if rejected_ids:
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id, TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.desc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "reject")
|
||||
).subquery()
|
||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||
for row in r:
|
||||
if row[1]:
|
||||
reject_op[row[0]] = row[1]
|
||||
|
||||
create_op: dict = {}
|
||||
if rejected_ids:
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id, TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.asc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "create")
|
||||
).subquery()
|
||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||
for row in r:
|
||||
if row[1]:
|
||||
create_op[row[0]] = row[1]
|
||||
|
||||
parent_assignee: dict = {}
|
||||
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op]
|
||||
if parent_ids:
|
||||
r = await db.execute(
|
||||
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
|
||||
)
|
||||
for row in r:
|
||||
if row[1]:
|
||||
parent_assignee[row[0]] = row[1]
|
||||
|
||||
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
|
||||
raw_ids: set[str] = set()
|
||||
for kind, (t, *_row) in all_rows:
|
||||
if kind == "rejected":
|
||||
raw_ids.add(reject_op.get(t.id) or "")
|
||||
raw_ids.add(create_op.get(t.id) or "")
|
||||
if t.id not in create_op and t.parent_task_id:
|
||||
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
|
||||
raw_ids.add(t.assignee_id or "")
|
||||
raw_ids.discard("")
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(list(raw_ids))
|
||||
|
||||
items: list[RejectedTask] = []
|
||||
for kind, (task, sn, ext, mat, spec) in all_rows:
|
||||
if kind == "rejected":
|
||||
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
|
||||
rework_id = create_op.get(task.id)
|
||||
if not rework_id and task.parent_task_id:
|
||||
rework_id = parent_assignee.get(task.parent_task_id)
|
||||
if not rework_id:
|
||||
rework_id = task.assignee_id
|
||||
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
||||
items.append(RejectedTask(
|
||||
task_id=str(task.id),
|
||||
kind="rejected",
|
||||
task_name=task.task_name,
|
||||
product_sn=sn or "",
|
||||
external_serial=ext or None,
|
||||
material_name=mat or "",
|
||||
spec_model=spec or "",
|
||||
rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or "—"),
|
||||
rework_assignee=name_map.get(rework_id or "", rework_id or "—"),
|
||||
reject_reason=task.reject_reason,
|
||||
status=task.status,
|
||||
rejected_at=_to_bj_iso(task.completed_at),
|
||||
))
|
||||
else:
|
||||
# 返工任务:负责人即其 assignee
|
||||
items.append(RejectedTask(
|
||||
task_id=str(task.id),
|
||||
kind="rework",
|
||||
task_name=task.task_name,
|
||||
product_sn=sn or "",
|
||||
external_serial=ext or None,
|
||||
material_name=mat or "",
|
||||
spec_model=spec or "",
|
||||
rejected_by="—",
|
||||
rework_assignee=name_map.get(task.assignee_id or "", task.assignee_id or "—"),
|
||||
reject_reason=None,
|
||||
status=task.status,
|
||||
rejected_at=_to_bj_iso(task.received_at or task.created_at),
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
|
||||
# ============================================================
|
||||
|
||||
@ -541,6 +541,34 @@ async def get_all_products(
|
||||
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
|
||||
latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
|
||||
|
||||
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时
|
||||
active_duration_map: dict[uuid.UUID, float] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import func as sa_func
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
start_stmt = (
|
||||
select(
|
||||
Task.product_id,
|
||||
sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)),
|
||||
)
|
||||
.where(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
)
|
||||
.group_by(Task.product_id)
|
||||
)
|
||||
start_result = await db.execute(start_stmt)
|
||||
now = get_beijing_time()
|
||||
for row in start_result:
|
||||
start = row[1]
|
||||
if start is None:
|
||||
continue
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
start = start.astimezone(BEIJING_TZ)
|
||||
active_duration_map[row[0]] = round((now - start).total_seconds() / 3600, 1)
|
||||
|
||||
return [
|
||||
ProductResponse(
|
||||
id=p.id,
|
||||
@ -575,6 +603,7 @@ async def get_all_products(
|
||||
merged_name_map.get(latest_record_map.get(p.id, (None, None, False, None))[3])
|
||||
if latest_record_map.get(p.id, (None, None, False, None))[3] else None
|
||||
),
|
||||
active_duration_hours=active_duration_map.get(p.id),
|
||||
)
|
||||
for p in products
|
||||
]
|
||||
|
||||
@ -37,11 +37,13 @@ services:
|
||||
container_name: track_backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@db:5432/track_production
|
||||
# 🚀 track 主库用容器名 track_db,避免与 MOM 的 inventory_db(别名 db) 在同一网络下 DNS 解析冲突
|
||||
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production
|
||||
SECRET_KEY: change-me-in-production
|
||||
DEBUG: "true"
|
||||
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","tauri://localhost"]'
|
||||
MOM_DB_HOST: 172.20.0.3
|
||||
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP
|
||||
MOM_DB_HOST: inventory_db
|
||||
MOM_DB_PORT: "5432"
|
||||
ports:
|
||||
- "8011:8000"
|
||||
@ -76,7 +78,7 @@ services:
|
||||
networks:
|
||||
mom_net:
|
||||
external: true
|
||||
name: inventory-backend_default
|
||||
name: projects_default
|
||||
|
||||
volumes:
|
||||
track_pgdata:
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 流转树双模式可视化 — 焦点模式 + 全景模式
|
||||
*/
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X } from "lucide-react";
|
||||
import { memo, useMemo, useState, useEffect } from "react";
|
||||
import { Image } from "antd";
|
||||
import { FileText, X } from "lucide-react";
|
||||
import type { TaskResponse } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
@ -15,12 +16,12 @@ function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d);
|
||||
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"; }
|
||||
function active(s: string) { return s === "WIP" || s === "PENDING"; }
|
||||
/** 微型右箭头 SVG */
|
||||
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
|
||||
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||||
function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||||
}
|
||||
/** 微型左箭头 SVG */
|
||||
function ArrowLeft({ color = "#9ca3af" }: { color?: string }) {
|
||||
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
|
||||
function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
|
||||
}
|
||||
function parseImages(s: any): string[] {
|
||||
if (!s) return [];
|
||||
@ -48,56 +49,92 @@ const ALL_TASKS = new Set<TaskResponse>();
|
||||
function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); }
|
||||
function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; }
|
||||
|
||||
// ============================================================
|
||||
// 尺寸映射表(sm=现状小卡片,lg=弹窗放大)
|
||||
// ============================================================
|
||||
const SIZE_MAP = {
|
||||
sm: {
|
||||
card: "w-44 p-2.5 shadow-sm",
|
||||
badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]",
|
||||
title: "mt-1 text-xs",
|
||||
metaRow: "mt-1",
|
||||
status: "px-1.5 py-px text-[8px]",
|
||||
assignee: "text-[9px]",
|
||||
time: "mt-1 text-[8px]",
|
||||
sub: "mt-1 text-[8px]",
|
||||
btnRow: "mt-1.5 pt-1.5",
|
||||
btn: "py-0.5 text-[8px]",
|
||||
record: "mt-1 px-1.5 py-0.5 text-[8px]",
|
||||
recordIcon: "h-2.5 w-2.5",
|
||||
},
|
||||
lg: {
|
||||
card: "w-72 p-4 shadow-md",
|
||||
badge: "-top-2 right-3 px-2 py-0.5 text-xs",
|
||||
title: "mt-1.5 text-base",
|
||||
metaRow: "mt-2",
|
||||
status: "px-2 py-0.5 text-xs",
|
||||
assignee: "text-sm",
|
||||
time: "mt-2 text-xs",
|
||||
sub: "mt-1.5 text-xs",
|
||||
btnRow: "mt-2.5 pt-2.5",
|
||||
btn: "py-1.5 text-sm",
|
||||
record: "mt-2 px-2.5 py-1 text-xs",
|
||||
recordIcon: "h-4 w-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// 极简卡片
|
||||
// ============================================================
|
||||
const SlimCard = memo(function SlimCard({
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
|
||||
}: {
|
||||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||||
onViewRecords?: (t: TaskResponse) => void;
|
||||
rootMainId?: string;
|
||||
size?: "sm" | "lg";
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
const sz = SIZE_MAP[size];
|
||||
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
||||
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
||||
const main = isMain(task);
|
||||
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
|
||||
|
||||
return (
|
||||
<div className={`relative w-44 shrink-0 rounded-lg border bg-white p-2.5 shadow-sm ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
|
||||
<div className={`absolute -top-1.5 right-2 rounded px-1.5 py-px text-[8px] font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
|
||||
<p className="mt-1 text-xs font-bold text-gray-800 truncate">{task.task_name}</p>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className={`rounded-full px-1.5 py-px text-[8px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
<span className="text-[9px] text-gray-400 truncate">{assigneeName || task.assignee_id || "—"}</span>
|
||||
<div className={`relative shrink-0 rounded-lg border bg-white ${sz.card} ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
|
||||
<div className={`absolute ${sz.badge} rounded font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
|
||||
<p className={`${sz.title} font-bold text-gray-800 truncate`}>{task.task_name}</p>
|
||||
<div className={`${sz.metaRow} flex items-center gap-1`}>
|
||||
<span className={`rounded-full ${sz.status} font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
<span className={`${sz.assignee} text-gray-400 truncate`}>{assigneeName || task.assignee_id || "—"}</span>
|
||||
</div>
|
||||
{/* 单行时间 */}
|
||||
<p className="mt-1 text-[8px] text-gray-300">
|
||||
<p className={`${sz.time} text-gray-300`}>
|
||||
⏰ {fmtTime(task.created_at).split(" ")[0]}
|
||||
{task.completed_at ? ` → ${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"}
|
||||
</p>
|
||||
{legacy && <p className="mt-1 text-[8px] text-orange-500">源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{legacy && <p className={`${sz.sub} text-orange-500`}>源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{/* 操作按钮 */}
|
||||
{active && isOwner && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-0.5 text-[8px] text-blue-600">接收</button>}
|
||||
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-0.5 text-[8px] text-green-600">转交</button>}
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-0.5 text-[8px] text-red-500">驳回</button>
|
||||
<div className={`${sz.btnRow} flex gap-1 border-t border-gray-100`}>
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className={`flex-1 rounded border border-blue-200 bg-blue-50 ${sz.btn} text-blue-600`}>接收</button>}
|
||||
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-green-200 bg-green-50 ${sz.btn} text-green-600`}>转交</button>}
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-red-200 bg-red-50 ${sz.btn} text-red-500`}>驳回</button>
|
||||
</div>
|
||||
)}
|
||||
{active && !isOwner && isManager && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-orange-100 pt-1.5">
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制转交</button>
|
||||
<div className={`${sz.btnRow} flex gap-1 border-t border-orange-100`}>
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}>强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}>强制转交</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 记录 */}
|
||||
{task.records && task.records.length > 0 && (
|
||||
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className="mt-1 cursor-pointer rounded bg-blue-50 px-1.5 py-0.5 text-[8px] text-blue-600 hover:bg-blue-100">
|
||||
<FileText className="mr-0.5 inline h-2.5 w-2.5" />{task.records.length}条
|
||||
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className={`${sz.record} cursor-pointer rounded bg-blue-50 text-blue-600 hover:bg-blue-100`}>
|
||||
<FileText className={`mr-0.5 inline ${sz.recordIcon}`} />{task.records.length}条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -111,12 +148,21 @@ interface TaskFlowViewProps {
|
||||
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
|
||||
currentUser?: { username?: string; role?: string } | null;
|
||||
assigneeNames?: Record<string, string>;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) {
|
||||
const [showFullMap, setShowFullMap] = useState(false);
|
||||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
|
||||
|
||||
// 🚀 记录弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
|
||||
useEffect(() => {
|
||||
if (!recordsTask) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [recordsTask]);
|
||||
|
||||
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
|
||||
const { allMains, childMap } = useMemo(() => {
|
||||
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
|
||||
@ -139,12 +185,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
}, [tasks]);
|
||||
|
||||
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
|
||||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
|
||||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => {
|
||||
const kids = childMap[node.id] || [];
|
||||
const big = size === "lg";
|
||||
const arrow = side === 'left'
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} /><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} />;
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
|
||||
const kidsContainer = kids.length > 0 ? (
|
||||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||||
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
|
||||
@ -178,7 +225,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* 模式切换 */}
|
||||
<div className="mb-3 flex justify-center">
|
||||
<button onClick={() => setShowFullMap(!showFullMap)}
|
||||
className="rounded-full bg-gray-100 px-4 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors">
|
||||
className={`rounded-full bg-gray-100 font-medium text-gray-600 hover:bg-gray-200 transition-colors ${size === "lg" ? "px-5 py-2 text-sm" : "px-4 py-1.5 text-xs"}`}>
|
||||
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
|
||||
</button>
|
||||
</div>
|
||||
@ -195,7 +242,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
return (
|
||||
<div key={mainTask.id} className="relative">
|
||||
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-0" />
|
||||
<div className={`absolute left-1/2 top-0 bottom-0 -translate-x-1/2 z-0 bg-gray-200 ${size === "lg" ? "w-1" : "w-0.5"}`} />
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||||
@ -204,9 +251,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* 中央 */}
|
||||
<div className="shrink-0 z-10 relative">
|
||||
<SlimCard task={mainTask} active={active(mainTask.status)}
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
|
||||
{active(mainTask.status) && (
|
||||
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
|
||||
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
|
||||
)}
|
||||
</div>
|
||||
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
@ -217,14 +264,14 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[10px] text-gray-300">▼</span>
|
||||
<span className={`text-gray-300 ${size === "lg" ? "text-sm" : "text-[10px]"}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleMains.length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400 py-8">
|
||||
<p className={`text-center text-gray-400 py-8 ${size === "lg" ? "text-sm" : "text-xs"}`}>
|
||||
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
|
||||
</p>
|
||||
)}
|
||||
@ -232,17 +279,21 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
{/* 记录弹窗 */}
|
||||
{recordsTask && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
|
||||
<div className="relative z-10 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl bg-white p-5 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between"><h3 className="text-sm font-bold">提交记录 — {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
<div className={`relative z-10 mx-4 max-h-[80vh] w-full overflow-y-auto rounded-xl bg-white shadow-2xl ${size === "lg" ? "max-w-2xl p-6" : "max-w-md p-5"}`}>
|
||||
<div className={`mb-3 flex items-center justify-between`}><h3 className={`font-bold ${size === "lg" ? "text-lg" : "text-sm"}`}>提交记录 — {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400">暂无记录</p> :
|
||||
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
|
||||
<div key={r.id} className="flex gap-2">
|
||||
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
|
||||
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p>
|
||||
{(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>}
|
||||
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-14 w-14 rounded border object-cover cursor-pointer hover:opacity-80 transition-opacity" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
|
||||
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className={`text-gray-400 ${size === "lg" ? "text-xs" : "text-[10px]"}`}>{fmtTime(r.created_at)}</p>
|
||||
{r.remark && <p className={`mt-0.5 text-gray-700 ${size === "lg" ? "text-sm" : "text-xs"}`}>{r.remark}</p>}
|
||||
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return (
|
||||
<Image.PreviewGroup>
|
||||
<div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <Image key={j} src={imageUrl(img)} width={size === "lg" ? 96 : 56} height={size === "lg" ? 96 : 56} className="rounded border object-cover" style={{ objectFit: "cover" }} />)}</div>
|
||||
</Image.PreviewGroup>
|
||||
); })()}
|
||||
</div>
|
||||
</div>
|
||||
))}</div>}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, memo } from "react";
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
@ -25,17 +25,29 @@ import { getStatusConfig } from "../../constants/task";
|
||||
// 通用 Modal 容器
|
||||
// ============================================================
|
||||
|
||||
const Modal = memo(function Modal({
|
||||
export const Modal = memo(function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
widthClass = "max-w-md",
|
||||
bodyClassName = "",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
widthClass?: string;
|
||||
bodyClassName?: string;
|
||||
}) {
|
||||
// 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
@ -46,7 +58,7 @@ const Modal = memo(function Modal({
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* 弹窗 */}
|
||||
<div className="relative z-10 mx-4 w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className={`relative z-10 mx-4 w-full ${widthClass} rounded-xl bg-white p-6 shadow-2xl ${bodyClassName}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-gray-800">{title}</h3>
|
||||
<button
|
||||
|
||||
@ -36,7 +36,7 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
|
||||
bg: "bg-red-50",
|
||||
text: "text-red-700",
|
||||
ring: "ring-red-400",
|
||||
label: "待接收",
|
||||
label: "已驳回",
|
||||
},
|
||||
[TASK_STATUS.ARCHIVED]: {
|
||||
bg: "bg-gray-50",
|
||||
|
||||
@ -7,8 +7,8 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Radio, DatePicker, Drawer, Input } from "antd";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask,
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
|
||||
} from "../../services/dashboardApi";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@ -188,6 +188,59 @@ function CompletedRow({ t }: { t: CompletedTask }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 被驳回明细项(卡片式) ──────────────────────────────
|
||||
function RejectedRow({ t }: { t: RejectedTask }) {
|
||||
const nav = useNavigate();
|
||||
const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : "";
|
||||
const isRework = t.kind === "rework";
|
||||
return (
|
||||
<div
|
||||
onClick={() => t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)}
|
||||
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />}
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
{isRework ? (
|
||||
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
{!isRework && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
|
||||
🔁 返工给: {t.rework_assignee}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
|
||||
</>
|
||||
)}
|
||||
{isRework && (
|
||||
<span className="truncate text-orange-600/80">🔁 返工任务 · {t.rework_assignee} 负责</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{t.spec_model || "无规格"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>序列号: {t.external_serial || "未录入"}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-mono text-gray-300">身份证: {t.product_sn}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主组件 ───────────────────────────────────────────────
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
@ -211,6 +264,11 @@ export default function AdminDashboard() {
|
||||
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
||||
const [completedLoading, setCompletedLoading] = useState(false);
|
||||
|
||||
// 驳回/返工明细抽屉
|
||||
const [rejectedDrawerOpen, setRejectedDrawerOpen] = useState(false);
|
||||
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
|
||||
const [rejectedLoading, setRejectedLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 加载主数据 ──
|
||||
@ -255,6 +313,16 @@ export default function AdminDashboard() {
|
||||
.finally(() => setCompletedLoading(false));
|
||||
};
|
||||
|
||||
const openRejectedDrawer = () => {
|
||||
setRejectedDrawerOpen(true);
|
||||
setRejectedLoading(true);
|
||||
const { since, until } = rangeToParams(dateKey, customRange);
|
||||
fetchRejectedTasks(since, until)
|
||||
.then(setRejectedTasks)
|
||||
.catch(() => setRejectedTasks([]))
|
||||
.finally(() => setRejectedLoading(false));
|
||||
};
|
||||
|
||||
const onMsgSearch = (value: string) => {
|
||||
setMsgKeyword(value);
|
||||
loadMessages(value);
|
||||
@ -358,10 +426,16 @@ export default function AdminDashboard() {
|
||||
<h3 className="text-sm font-semibold text-gray-700">⚠️ 品质与协同</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-red-50 p-3 text-center">
|
||||
{/* 驳回/返工 — 可点击打开明细抽屉 */}
|
||||
<button
|
||||
onClick={openRejectedDrawer}
|
||||
className="rounded-lg bg-red-50 p-3 text-center border-0 cursor-pointer transition-colors hover:bg-red-100"
|
||||
>
|
||||
<p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
|
||||
<p className="text-[11px] text-red-500">驳回/返工</p>
|
||||
</div>
|
||||
<p className="flex items-center justify-center gap-1 text-[11px] text-red-500">
|
||||
<AlertTriangle className="h-3 w-3" />驳回/返工 ↗
|
||||
</p>
|
||||
</button>
|
||||
{/* 留言 — 可点击打开抽屉 */}
|
||||
<button
|
||||
onClick={openMsgDrawer}
|
||||
@ -527,6 +601,52 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ═══ 驳回/返工明细抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>}
|
||||
open={rejectedDrawerOpen}
|
||||
onClose={() => setRejectedDrawerOpen(false)}
|
||||
size="large"
|
||||
styles={{ body: { padding: 16, background: "#f8fafc" } }}
|
||||
>
|
||||
{rejectedLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-red-500" />
|
||||
</div>
|
||||
) : rejectedTasks.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">
|
||||
该时段暂无驳回/返工记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
|
||||
const rework = rejectedTasks.filter(t => t.kind === "rework");
|
||||
return (
|
||||
<>
|
||||
{rejected.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
|
||||
</h4>
|
||||
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
{rework.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
|
||||
<RefreshCw className="h-3.5 w-3.5" />返工任务 · {rework.length} 条
|
||||
</h4>
|
||||
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
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,
|
||||
} from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
import {
|
||||
getLabelPreview, executePrint, type LabelPreviewRequest,
|
||||
getLabelPreview, executePrint,
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
{ key: "ARCHIVED", label: "已入库" },
|
||||
];
|
||||
|
||||
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -34,7 +34,7 @@ export default function AdminProductsPage() {
|
||||
|
||||
// 搜索 & 筛选 & 分组
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
|
||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
@ -73,13 +73,13 @@ export default function AdminProductsPage() {
|
||||
|
||||
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
|
||||
|
||||
// ---- 分组 + 状态过滤 ----
|
||||
// ---- 分组 + 状态过滤(多选)----
|
||||
const productGroups = useMemo<ProductGroup[]>(() => {
|
||||
const map = new Map<string, ProductResponse[]>();
|
||||
for (const p of products) {
|
||||
if (statusFilter) {
|
||||
if (statusFilters.size > 0) {
|
||||
const s = (p.macro_status || p.status).toUpperCase();
|
||||
if (s !== statusFilter) continue;
|
||||
if (!statusFilters.has(s)) continue;
|
||||
}
|
||||
const key = groupBy === "device"
|
||||
? (p.material_name || p.material_id || "未命名设备")
|
||||
@ -91,7 +91,15 @@ export default function AdminProductsPage() {
|
||||
groupKey, products: prods,
|
||||
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 变化时自动展开所有面板
|
||||
useEffect(() => {
|
||||
@ -161,12 +169,19 @@ export default function AdminProductsPage() {
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
{STATUS_TABS.map(tab => (
|
||||
<button key={tab.key} onClick={() => setStatusFilter(tab.key)}
|
||||
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"}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setStatusFilters(new Set())}
|
||||
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"}`}>
|
||||
全部
|
||||
</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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -210,10 +225,10 @@ export default function AdminProductsPage() {
|
||||
<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">
|
||||
{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">
|
||||
<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={() => 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(); 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(); 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 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" />
|
||||
@ -225,10 +240,21 @@ export default function AdminProductsPage() {
|
||||
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
|
||||
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
|
||||
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Timer className="h-3 w-3 shrink-0 text-gray-400" />
|
||||
<span className="shrink-0 text-gray-400">当前人时间</span>
|
||||
{(() => {
|
||||
const h = p.active_duration_hours;
|
||||
if (h == null) return <span className="truncate font-medium text-gray-700">—</span>;
|
||||
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
|
||||
return <span className={`rounded-md px-1.5 py-0.5 font-bold ${cls}`}>{formatDuration(h)}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<InfoRow icon={CalendarDays} label="生产总天数" value={formatProductionDays(p.created_at)} />
|
||||
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
|
||||
</div>
|
||||
<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" />打印标签
|
||||
</button>
|
||||
</div>
|
||||
@ -294,3 +320,17 @@ export default function AdminProductsPage() {
|
||||
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>;
|
||||
}
|
||||
|
||||
/** 滞留时长格式化:小时 → 天/小时/分钟 */
|
||||
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} 天`;
|
||||
}
|
||||
|
||||
@ -3,9 +3,9 @@ import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||
Warehouse, GitBranch, X,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
|
||||
} from "lucide-react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, Popover, Checkbox, Input } from "antd";
|
||||
import api from "../../services/api";
|
||||
import { scanProduct } from "../../services/productApi";
|
||||
import {
|
||||
@ -13,27 +13,47 @@ import {
|
||||
} from "../../services/taskApi";
|
||||
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
{ key: "ARCHIVED", label: "已入库" },
|
||||
];
|
||||
|
||||
type SortOrder = "asc" | "desc";
|
||||
|
||||
interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
colSpan: number;
|
||||
sortable?: boolean;
|
||||
sortValue?: (p: ProductResponse) => string | number;
|
||||
filterType?: "text" | "enum";
|
||||
getFilterValue?: (p: ProductResponse) => string;
|
||||
enumOptions?: { value: string; label: string }[];
|
||||
render: (p: ProductResponse) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface OrderGroup {
|
||||
orderNo: string;
|
||||
products: ProductResponse[];
|
||||
allInWarehouse: boolean;
|
||||
}
|
||||
|
||||
/** 滞留时长格式化:小时 → 天/小时/分钟 */
|
||||
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))}分钟`;
|
||||
}
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
@ -41,7 +61,7 @@ export default function AdminTasksPage() {
|
||||
|
||||
// 搜索 & 筛选
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
|
||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -60,6 +80,121 @@ export default function AdminTasksPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
|
||||
|
||||
// ---- 列配置(10列)----
|
||||
const columns: ColumnDef[] = [
|
||||
{
|
||||
key: "serial_number", label: "产品身份证", colSpan: 2,
|
||||
sortable: true, sortValue: (p) => p.serial_number,
|
||||
filterType: "text", getFilterValue: (p) => p.serial_number,
|
||||
render: (p) => (
|
||||
<div className="font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "external_serial", label: "序列号", colSpan: 1,
|
||||
sortable: true, sortValue: (p) => p.external_serial || "",
|
||||
filterType: "text", getFilterValue: (p) => p.external_serial || "",
|
||||
render: (p) => <div className="font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>,
|
||||
},
|
||||
{
|
||||
key: "spec", label: "规格型号", colSpan: 2,
|
||||
filterType: "text", getFilterValue: (p) => p.spec_model || p.material_name || p.material_id || "",
|
||||
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
|
||||
},
|
||||
{
|
||||
key: "overall_status", label: "宏观状态", colSpan: 1,
|
||||
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
|
||||
enumOptions: ["备货", "生产", "测试", "维修", "在库"].map((v) => ({ value: v, label: v })),
|
||||
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
|
||||
},
|
||||
{
|
||||
key: "status", label: "任务状态", colSpan: 1,
|
||||
filterType: "enum", getFilterValue: (p) => (p.macro_status || p.status).toUpperCase(),
|
||||
enumOptions: [
|
||||
{ value: "PENDING", label: "待接收" },
|
||||
{ value: "WIP", label: "进行中" },
|
||||
{ value: "COMPLETED", label: "已完成" },
|
||||
],
|
||||
render: (p) => {
|
||||
const statusCfg = getStatusConfig(p.macro_status || p.status);
|
||||
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "location", label: "当前位置", colSpan: 1,
|
||||
filterType: "enum",
|
||||
getFilterValue: (p) => p.current_location_id === "virtual_warehouse" ? "仓库" : (p.current_location_name || p.current_location_id || "—"),
|
||||
render: (p) => (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{p.current_location_id === "virtual_warehouse" ? <span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span> : (p.current_location_name || p.current_location_id || "—")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "active_duration", label: "当前人滞留", colSpan: 1,
|
||||
sortable: true, sortValue: (p) => p.active_duration_hours ?? -1,
|
||||
render: (p) => {
|
||||
const h = p.active_duration_hours;
|
||||
if (h == null) return <span className="text-xs text-gray-300">—</span>;
|
||||
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
|
||||
return <span className={`rounded-md px-1.5 py-0.5 text-xs font-bold ${cls}`}>{formatDuration(h)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "latest", label: "最新动态", colSpan: 2,
|
||||
sortable: true, sortValue: (p) => p.latest_record_time ? new Date(p.latest_record_time).getTime() : 0,
|
||||
filterType: "text",
|
||||
getFilterValue: (p) => {
|
||||
const t = p.latest_record_time ? new Date(p.latest_record_time).toLocaleString("zh-CN") : "";
|
||||
return `${p.latest_record_assignee_name || ""} ${p.latest_record_content || ""} ${t}`;
|
||||
},
|
||||
render: (p) => (
|
||||
p.latest_record_time ? (
|
||||
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
|
||||
<div className="cursor-default">
|
||||
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
|
||||
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
|
||||
<span className="truncate">
|
||||
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
|
||||
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : <span className="text-gray-300">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at", label: "创建时间", colSpan: 2,
|
||||
sortable: true, sortValue: (p) => new Date(p.created_at).getTime(),
|
||||
filterType: "text", getFilterValue: (p) => new Date(p.created_at).toLocaleDateString("zh-CN"),
|
||||
render: (p) => <div className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>,
|
||||
},
|
||||
{
|
||||
key: "actions", label: "操作", colSpan: 2,
|
||||
render: (p) => {
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
return (
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ---- 列顺序 / 行排序 / 列筛选状态 ----
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
|
||||
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
|
||||
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
|
||||
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
|
||||
const [dragCol, setDragCol] = useState<string | null>(null);
|
||||
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
|
||||
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
|
||||
const autoSn = searchParams.get("sn") || "";
|
||||
|
||||
@ -85,9 +220,9 @@ export default function AdminTasksPage() {
|
||||
if (autoSn) {
|
||||
setKeyword(autoSn);
|
||||
loadProducts(autoSn).then((data) => {
|
||||
// 产品加载完成后自动展开流转树
|
||||
// 产品加载完成后自动打开流转树弹窗
|
||||
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
|
||||
if (found) toggleProductTree(found.serial_number);
|
||||
if (found) openTreeModal(found.serial_number);
|
||||
});
|
||||
} else {
|
||||
loadProducts(keyword);
|
||||
@ -96,19 +231,40 @@ export default function AdminTasksPage() {
|
||||
|
||||
function handleSearch(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
setExpandedProducts(new Set());
|
||||
setExpandedOrders(new Set());
|
||||
setTaskTrees({});
|
||||
setActiveTreeProductId(null);
|
||||
loadProducts(keyword);
|
||||
}
|
||||
|
||||
// ---- 按订单/设备分组 + 本地状态过滤 ----
|
||||
// 🚀 二维码弹窗打开时锁定背景滚动(防止滚动穿透)
|
||||
useEffect(() => {
|
||||
if (!qrSerial) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [qrSerial]);
|
||||
|
||||
// ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ----
|
||||
const orderGroups = useMemo<OrderGroup[]>(() => {
|
||||
const map = new Map<string, ProductResponse[]>();
|
||||
for (const p of products) {
|
||||
if (statusFilter) {
|
||||
if (statusFilters.size > 0) {
|
||||
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
||||
if (currentStatus !== statusFilter) continue;
|
||||
if (!statusFilters.has(currentStatus)) continue;
|
||||
}
|
||||
// 列筛选(所有列)
|
||||
let skip = false;
|
||||
for (const col of columns) {
|
||||
if (col.filterType === "text") {
|
||||
const kw = (textFilters[col.key] || "").trim().toLowerCase();
|
||||
if (kw && !(col.getFilterValue!(p) || "").toLowerCase().includes(kw)) { skip = true; break; }
|
||||
} else if (col.filterType === "enum") {
|
||||
const set = enumFilters[col.key];
|
||||
if (set && set.size > 0 && !set.has(col.getFilterValue!(p))) { skip = true; break; }
|
||||
}
|
||||
}
|
||||
if (skip) continue;
|
||||
const key = groupBy === "device"
|
||||
? (p.material_name || p.material_id || "未命名设备")
|
||||
: (p.order_no || "未绑定订单");
|
||||
@ -117,14 +273,96 @@ export default function AdminTasksPage() {
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([orderNo, prods]) => ({
|
||||
orderNo,
|
||||
products: prods,
|
||||
allInWarehouse: prods.every(
|
||||
(p) => p.current_location_id === "virtual_warehouse"
|
||||
),
|
||||
}));
|
||||
}, [products, statusFilter, groupBy]);
|
||||
.map(([orderNo, prods]) => {
|
||||
// 组内排序(升/降)
|
||||
let sorted = prods;
|
||||
if (sort) {
|
||||
const col = columns.find((c) => c.key === sort!.key);
|
||||
if (col?.sortValue) {
|
||||
sorted = [...prods].sort((a, b) => {
|
||||
const va = col.sortValue!(a);
|
||||
const vb = col.sortValue!(b);
|
||||
const cmp = (typeof va === "number" && typeof vb === "number")
|
||||
? va - vb
|
||||
: String(va ?? "").localeCompare(String(vb ?? ""));
|
||||
return sort!.order === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
orderNo,
|
||||
products: sorted,
|
||||
allInWarehouse: prods.every(
|
||||
(p) => p.current_location_id === "virtual_warehouse"
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function toggleStatusFilter(key: string) {
|
||||
setStatusFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 列头排序:无 → 升序 → 降序 → 无 ----
|
||||
function toggleSort(key: string) {
|
||||
setSort((prev) => {
|
||||
if (!prev || prev.key !== key) return { key, order: "asc" };
|
||||
if (prev.order === "asc") return { key, order: "desc" };
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 列显隐:勾选=显示,取消=隐藏(操作列始终显示) ----
|
||||
function toggleColumnVisible(key: string) {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 枚举列多选筛选 ----
|
||||
function toggleEnumFilter(key: string, value: string) {
|
||||
setEnumFilters((prev) => {
|
||||
const cur = prev[key] || new Set<string>();
|
||||
const next = new Set(cur);
|
||||
if (next.has(value)) next.delete(value); else next.add(value);
|
||||
const copy = { ...prev };
|
||||
if (next.size === 0) delete copy[key];
|
||||
else copy[key] = next;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 列头拖拽换序 ----
|
||||
function onDragStart(e: React.DragEvent, key: string) {
|
||||
setDragCol(key);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
function onDragOver(e: React.DragEvent, key: string) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverCol(key);
|
||||
}
|
||||
function onDrop(e: React.DragEvent, targetKey: string) {
|
||||
e.preventDefault();
|
||||
if (!dragCol || dragCol === targetKey) { setDragCol(null); setDragOverCol(null); return; }
|
||||
setColumnOrder((prev) => {
|
||||
const next = [...prev];
|
||||
const from = next.indexOf(dragCol);
|
||||
const to = next.indexOf(targetKey);
|
||||
if (from < 0 || to < 0) return prev;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, dragCol);
|
||||
return next;
|
||||
});
|
||||
setDragCol(null);
|
||||
setDragOverCol(null);
|
||||
}
|
||||
|
||||
// 🔧 默认全部展开
|
||||
useEffect(() => {
|
||||
@ -141,14 +379,8 @@ export default function AdminTasksPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 排他展开(手风琴模式) ----
|
||||
async function toggleProductTree(serialNumber: string) {
|
||||
// 点击已展开的树 → 收起
|
||||
if (activeTreeProductId === serialNumber) {
|
||||
setActiveTreeProductId(null);
|
||||
return;
|
||||
}
|
||||
// 展开新的 → 自动收起旧的
|
||||
// ---- 流转树宽屏弹窗(懒加载,缓存到 taskTrees) ----
|
||||
function openTreeModal(serialNumber: string) {
|
||||
setActiveTreeProductId(serialNumber);
|
||||
if (!taskTrees[serialNumber]) {
|
||||
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
|
||||
@ -158,6 +390,9 @@ export default function AdminTasksPage() {
|
||||
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
|
||||
}
|
||||
}
|
||||
function closeTreeModal() {
|
||||
setActiveTreeProductId(null);
|
||||
}
|
||||
|
||||
// ---- 任务操作 ----
|
||||
const refreshProductTree = useCallback(async (serialNumber: string) => {
|
||||
@ -227,6 +462,67 @@ export default function AdminTasksPage() {
|
||||
// ---- 渲染 ----
|
||||
const modalTask = modalTarget?.task ?? null;
|
||||
|
||||
// 按当前列顺序渲染的列
|
||||
const orderedColumns = columnOrder
|
||||
.map((key) => columns.find((c) => c.key === key))
|
||||
.filter((c): c is ColumnDef => !!c);
|
||||
|
||||
// 🔧 实际显示的列(排除被隐藏的)+ 动态总跨度
|
||||
const visibleColumns = orderedColumns.filter((c) => !hiddenColumns.has(c.key));
|
||||
const visibleSpan = visibleColumns.reduce((s, c) => s + c.colSpan, 0);
|
||||
|
||||
// 列设置面板内容(列显隐)
|
||||
function renderColumnPanel() {
|
||||
const toggleable = columns.filter((c) => c.key !== "actions");
|
||||
return (
|
||||
<div className="max-h-80 w-48 overflow-auto p-2">
|
||||
{toggleable.map((col) => (
|
||||
<label key={col.key} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
|
||||
<Checkbox checked={!hiddenColumns.has(col.key)} onChange={() => toggleColumnVisible(col.key)} />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="mt-1 flex items-center justify-between border-t border-gray-100 pt-1.5">
|
||||
<button onClick={() => setHiddenColumns(new Set())} className="text-xs text-blue-600 hover:underline">全部显示</button>
|
||||
<span className="text-[10px] text-gray-300">操作列固定</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 列筛选面板内容
|
||||
function renderFilterPanel(col: ColumnDef) {
|
||||
if (col.filterType === "text") {
|
||||
return (
|
||||
<div className="w-52 p-2">
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={`筛选${col.label}`}
|
||||
value={textFilters[col.key] || ""}
|
||||
onChange={(e) => setTextFilters((prev) => ({ ...prev, [col.key]: e.target.value }))}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// enum:优先固定选项,否则从当前数据动态去重
|
||||
const options = col.enumOptions
|
||||
|| Array.from(new Set(products.map((p) => col.getFilterValue!(p)))).filter(Boolean).map((v) => ({ value: v, label: v }));
|
||||
const selected = enumFilters[col.key] || new Set<string>();
|
||||
return (
|
||||
<div className="max-h-64 w-44 overflow-auto p-2">
|
||||
{options.length === 0 ? (
|
||||
<div className="py-2 text-center text-xs text-gray-400">无可用选项</div>
|
||||
) : options.map((opt) => (
|
||||
<label key={opt.value} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
|
||||
<Checkbox checked={selected.has(opt.value)} onChange={() => toggleEnumFilter(col.key, opt.value)} />
|
||||
<span className="truncate">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ---- 标题 ---- */}
|
||||
@ -249,7 +545,12 @@ export default function AdminTasksPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover trigger="click" placement="bottomRight" content={renderColumnPanel()}>
|
||||
<button className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-blue-600">
|
||||
<Columns className="h-3 w-3" />列设置
|
||||
</button>
|
||||
</Popover>
|
||||
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
|
||||
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50">全部展开</button>
|
||||
<button onClick={() => setExpandedOrders(new Set())}
|
||||
@ -286,21 +587,34 @@ export default function AdminTasksPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 状态筛选 Tabs */}
|
||||
{/* 状态筛选 Tabs(多选) */}
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setStatusFilters(new Set())}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -377,102 +691,61 @@ export default function AdminTasksPage() {
|
||||
{/* 订单展开内容 */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100">
|
||||
{/* 表头 */}
|
||||
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
|
||||
<div className="col-span-2">产品身份证</div>
|
||||
<div className="col-span-1">序列号</div>
|
||||
<div className="col-span-2">规格型号</div>
|
||||
<div className="col-span-1">宏观状态</div>
|
||||
<div className="col-span-1">任务状态</div>
|
||||
<div className="col-span-1">当前位置</div>
|
||||
<div className="col-span-2">最新动态</div>
|
||||
<div className="col-span-2">创建时间</div>
|
||||
<div className="col-span-2">操作</div>
|
||||
{/* 表头(可排序 / 可筛选 / 可拖拽换列 / 可显隐) */}
|
||||
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
|
||||
{visibleColumns.map((col) => {
|
||||
const sorted = sort?.key === col.key;
|
||||
const hasFilter = col.filterType === "text"
|
||||
? !!(textFilters[col.key] || "").trim()
|
||||
: !!enumFilters[col.key]?.size;
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, col.key)}
|
||||
onDragOver={(e) => onDragOver(e, col.key)}
|
||||
onDrop={(e) => onDrop(e, col.key)}
|
||||
onDragEnd={() => { setDragCol(null); setDragOverCol(null); }}
|
||||
className={`flex select-none items-center gap-1 rounded ${dragOverCol === col.key ? "bg-blue-100 ring-2 ring-blue-300" : ""}`}
|
||||
style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}
|
||||
>
|
||||
<span
|
||||
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
|
||||
className={`flex items-center gap-0.5 ${col.sortable ? "cursor-pointer hover:text-blue-600" : ""}`}
|
||||
>
|
||||
{col.label}
|
||||
{col.sortable && (
|
||||
sorted
|
||||
? (sort!.order === "asc" ? <ArrowUp className="h-3 w-3 text-blue-600" /> : <ArrowDown className="h-3 w-3 text-blue-600" />)
|
||||
: <ArrowUpDown className="h-3 w-3 text-gray-300" />
|
||||
)}
|
||||
</span>
|
||||
{col.filterType && (
|
||||
<Popover trigger="click" placement="bottomLeft" content={renderFilterPanel(col)}>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`rounded p-0.5 hover:bg-gray-200 ${hasFilter ? "text-blue-600" : "text-gray-400"}`}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 产品行 */}
|
||||
{group.products.map((p) => {
|
||||
const productExpanded = activeTreeProductId === p.serial_number;
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
const tree = taskTrees[p.serial_number];
|
||||
// 综合状态:优先流转树状态,兜底产品状态
|
||||
// 单一数据源:后端预计算 macro_status,兜底产品 status
|
||||
const currentStatus = p.macro_status || p.status;
|
||||
const statusCfg = getStatusConfig(currentStatus);
|
||||
|
||||
return (
|
||||
<div key={p.id}>
|
||||
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
|
||||
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
|
||||
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
|
||||
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
|
||||
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
|
||||
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
|
||||
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 仓库</span>) : (p.current_location_name || p.current_location_id || "—")}</div>
|
||||
{/* 最新动态 */}
|
||||
<div className="col-span-2 text-xs">
|
||||
{p.latest_record_time ? (
|
||||
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
|
||||
<div className="cursor-default">
|
||||
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
|
||||
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
|
||||
<span className="truncate">
|
||||
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
|
||||
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : <span className="text-gray-300">—</span>}
|
||||
</div>
|
||||
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
onClick={() => toggleProductTree(p.serial_number)}
|
||||
className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
{isTreeLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : productExpanded ? (
|
||||
<X className="h-3 w-3" />
|
||||
) : (
|
||||
<GitBranch className="h-3 w-3" />
|
||||
)}
|
||||
{productExpanded ? "收起" : "流转树"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
|
||||
{visibleColumns.map((col) => (
|
||||
<div key={col.key} style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}>
|
||||
{col.render(p)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 展开的流转树 — 卡片堆叠视图 */}
|
||||
{productExpanded && tree && (
|
||||
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
流转卡片 — {p.serial_number}
|
||||
</h4>
|
||||
{tree.task_tree && tree.task_tree.length > 0 ? (
|
||||
<TaskFlowView
|
||||
tasks={tree.task_tree}
|
||||
onAction={setModalTarget}
|
||||
currentUser={currentUser}
|
||||
assigneeNames={tree.assignee_names}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{productExpanded && isTreeLoading && (
|
||||
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
|
||||
<p className="mt-2 text-xs text-gray-400">加载流转树...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -497,6 +770,37 @@ export default function AdminTasksPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔧 流转树宽屏弹窗 */}
|
||||
{activeTreeProductId && (
|
||||
<Modal
|
||||
open
|
||||
onClose={closeTreeModal}
|
||||
title={`🔀 流转树 — ${activeTreeProductId}`}
|
||||
widthClass="max-w-6xl"
|
||||
bodyClassName="max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{treeLoading[activeTreeProductId] ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
) : taskTrees[activeTreeProductId]?.task_tree?.length ? (
|
||||
<TaskFlowView
|
||||
tasks={taskTrees[activeTreeProductId].task_tree}
|
||||
onAction={setModalTarget}
|
||||
currentUser={currentUser}
|
||||
assigneeNames={taskTrees[activeTreeProductId].assignee_names}
|
||||
size="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<GitBranch className="mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ---- 弹窗 ---- */}
|
||||
<ReceiveConfirmModal
|
||||
open={modalTarget?.action === "receive"}
|
||||
|
||||
@ -39,6 +39,21 @@ export interface CompletedTask {
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface RejectedTask {
|
||||
task_id: string;
|
||||
kind: "rejected" | "rework";
|
||||
task_name: string;
|
||||
product_sn: string;
|
||||
external_serial: string | null;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
rejected_by: string;
|
||||
rework_assignee: string;
|
||||
reject_reason: string | null;
|
||||
status: string | null;
|
||||
rejected_at: string | null;
|
||||
}
|
||||
|
||||
export interface PersonDevice {
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
@ -119,6 +134,14 @@ export async function fetchCompletedTasks(since?: string, until?: string): Promi
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchRejectedTasks(since?: string, until?: string): Promise<RejectedTask[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (since) params.since = since;
|
||||
if (until) params.until = until;
|
||||
const { data } = await api.get<RejectedTask[]>("/dashboard/rejected-tasks", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
||||
return data;
|
||||
|
||||
@ -23,6 +23,7 @@ export interface ProductResponse {
|
||||
latest_record_has_images: boolean;
|
||||
latest_record_assignee_id: string | null;
|
||||
latest_record_assignee_name: string | null;
|
||||
active_duration_hours: number | null;
|
||||
}
|
||||
|
||||
/** MOM 物料选项 */
|
||||
|
||||
Reference in New Issue
Block a user