Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions bia-admin/app/(admin)/admin/shipping/contacts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import { toast } from "sonner";
import { useCanWrite } from "@/lib/auth/role-context";
import { errText } from "@/lib/shipping/labels";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -27,6 +29,7 @@ const CONTACT_TYPE_LABELS: Record<ShippingContactType, string> = {
};

export default function AdminShippingContactsPage() {
const canWrite = useCanWrite();
const [contacts, setContacts] = useState<ShippingContact[]>([]);
const [drafts, setDrafts] = useState<Record<string, ContactDraft>>({});
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -64,7 +67,7 @@ export default function AdminShippingContactsPage() {
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: string };
toast.error(err.error ?? "保存失败");
toast.error(errText(err, "保存失败"));
return;
}
const updated = (await res.json()) as ShippingContact;
Expand All @@ -75,6 +78,8 @@ export default function AdminShippingContactsPage() {
return nextState;
});
toast.success("已保存");
} catch {
toast.error("保存失败,请检查网络后重试");
} finally {
setSavingId(null);
}
Expand Down Expand Up @@ -231,7 +236,7 @@ export default function AdminShippingContactsPage() {
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
disabled={uploadingId === c.id}
disabled={!canWrite || uploadingId === c.id}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadQr(c.id, f);
Expand Down Expand Up @@ -274,7 +279,7 @@ export default function AdminShippingContactsPage() {
<Button
type="button"
onClick={() => patchContact(c.id)}
disabled={!dirty || savingId === c.id}
disabled={!canWrite || !dirty || savingId === c.id}
>
{savingId === c.id ? "保存中…" : dirty ? "保存" : "未修改"}
</Button>
Expand Down
13 changes: 9 additions & 4 deletions bia-admin/app/(admin)/admin/shipping/pack-requests/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { useCanWrite } from "@/lib/auth/role-context";
import { shipmentStatusLabel } from "@/lib/shipping/labels";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -40,14 +42,15 @@ const STATUS_CLASS: Record<PackRequestStatus, string> = {
const OPEN_SHIPMENT_STATUSES = ["forming", "sealed"] as const;

function fmtDate(iso: string): string {
return new Intl.DateTimeFormat("en-US", {
return new Intl.DateTimeFormat("zh-CN", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(iso));
}

export default function AdminPackRequestsPage() {
const canWrite = useCanWrite();
const [requests, setRequests] = useState<PackRequestWithParcels[]>([]);
const [shipments, setShipments] = useState<Shipment[]>([]);
const [pickedShipment, setPickedShipment] = useState<Record<string, string>>(
Expand Down Expand Up @@ -338,7 +341,7 @@ export default function AdminPackRequestsPage() {
<option value="">选一个批次…</option>
{shipments.map((s) => (
<option key={s.id} value={s.id}>
{s.name} [{s.status}]
{s.name}({shipmentStatusLabel(s.status)})
{s.carrier ? ` · ${s.carrier}` : ""}
</option>
))}
Expand All @@ -348,7 +351,9 @@ export default function AdminPackRequestsPage() {
className="shrink-0"
onClick={() => attachToShipment(r.id)}
disabled={
!pickedShipment[r.id] || attachingId === r.id
!canWrite ||
!pickedShipment[r.id] ||
attachingId === r.id
}
>
{attachingId === r.id ? "处理中…" : "拍进批次"}
Expand Down Expand Up @@ -421,7 +426,7 @@ export default function AdminPackRequestsPage() {
<Button
type="button"
onClick={() => save(r.id)}
disabled={!dirty || savingId === r.id}
disabled={!canWrite || !dirty || savingId === r.id}
>
{savingId === r.id ? "保存中…" : dirty ? "保存" : "未修改"}
</Button>
Expand Down
55 changes: 51 additions & 4 deletions bia-admin/app/(admin)/admin/shipping/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,40 @@ export default async function ShippingOverviewPage() {
const admin = createBiaServiceRoleClient();
const head = { count: "exact" as const, head: true };

const counts = await Promise.all(
STAT_DEFS.map((s) =>
admin.from("parcels").select("id", head).eq("status", s.key),
const [counts, pendingPack, pendingRequests] = await Promise.all([
Promise.all(
STAT_DEFS.map((s) =>
admin.from("parcels").select("id", head).eq("status", s.key),
),
),
);
// Open work queues — these used to be invisible until someone happened to
// open the page (SR-7).
admin
.from("pack_requests")
.select("id", head)
.in("status", ["pending", "contacted"]),
admin
.from("shipment_requests")
.select("id", head)
.in("status", ["pending", "contacted"]),
]);

const QUEUE_DEFS = [
{
key: "pack",
label: "打包申请待处理",
hint: "pending / contacted 的打包申请",
href: "/admin/shipping/pack-requests?status=pending",
count: pendingPack.count ?? 0,
},
{
key: "requests",
label: "发货请求待处理",
hint: "pending / contacted 的发货请求",
href: "/admin/shipping/requests?status=pending",
count: pendingRequests.count ?? 0,
},
] as const;

return (
<div className="space-y-8 p-8">
Expand All @@ -88,6 +117,24 @@ export default async function ShippingOverviewPage() {
))}
</div>

<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{QUEUE_DEFS.map((s) => (
<Link
key={s.key}
href={s.href}
className={`group rounded-lg border p-5 transition-colors hover:border-primary ${
s.count > 0 ? "border-amber-300 bg-amber-50" : "bg-card"
}`}
>
<div className="text-sm font-medium text-foreground">{s.label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">
{s.count}
</div>
<p className="mt-1 text-xs text-muted-foreground">{s.hint}</p>
</Link>
))}
</div>

<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
快捷入口
Expand Down
86 changes: 84 additions & 2 deletions bia-admin/app/(admin)/admin/shipping/parcels/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function numOrNull(s: string): number | null {
}

function fmtTime(iso: string): string {
return new Intl.DateTimeFormat("en-US", {
return new Intl.DateTimeFormat("zh-CN", {
month: "short",
day: "numeric",
hour: "numeric",
Expand Down Expand Up @@ -82,6 +82,8 @@ export default function AdminParcelDetailPage() {
const [draftNotes, setDraftNotes] = useState("");
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [reassignOpen, setReassignOpen] = useState(false);
const [reassignMember, setReassignMember] = useState("");

const loadParcel = useCallback(async () => {
setLoading(true);
Expand Down Expand Up @@ -231,6 +233,47 @@ export default function AdminParcelDetailPage() {
}
};

const handleReassign = async () => {
const member = reassignMember.trim();
if (!parcel || !member || member === parcel.member_id) return;
if (
!window.confirm(
`把这个包裹重新指派给「${member}」?通知、学生端可见性和取件码都会跟随新学生。`,
)
)
return;
setSaving(true);
try {
const res = await fetch(`/api/admin/shipping/parcels/${id}/reassign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ member_id: member }),
});
const data = (await res.json().catch(() => ({}))) as {
error?: string;
detail?: string;
linked?: boolean;
};
if (!res.ok) {
toast.error(data.detail ?? data.error ?? "重新指派失败");
return;
}
if (data.linked) {
toast.success(`已重新指派给 ${member}(已关联学生档案)`);
} else {
toast.warning(
`已改为 ${member},但没有匹配到 students 档案 — 学生端将看不到该包裹,通知也不会发送。`,
);
}
setReassignOpen(false);
await loadParcel();
} catch {
toast.error("重新指派失败,请检查网络后重试");
} finally {
setSaving(false);
}
};

const handleRevertPickup = async () => {
if (!parcel) return;
const reason = window.prompt(
Expand Down Expand Up @@ -297,15 +340,54 @@ export default function AdminParcelDetailPage() {
<h1 className="mt-1 text-2xl font-bold tracking-tight">
{parcel.description}
</h1>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium">{parcel.member_id}</span>
{!parcel.student_id && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-amber-700">
未绑学生档案
</span>
)}
{parcel.shipping_method && (
<span>
· {SHIPPING_METHOD_META[parcel.shipping_method].icon}{" "}
{SHIPPING_METHOD_META[parcel.shipping_method].label}
</span>
)}
{canWrite && parcel.status !== "picked_up" && (
<button
type="button"
className="text-primary hover:underline"
onClick={() => {
setReassignMember(parcel.member_id);
setReassignOpen((v) => !v);
}}
>
重新指派
</button>
)}
</div>
{reassignOpen && (
<div className="mt-2 flex items-center gap-2">
<Input
value={reassignMember}
onChange={(e) => setReassignMember(e.target.value)}
placeholder="新 Member ID"
className="h-8 w-44"
/>
<Button
type="button"
size="sm"
onClick={() => void handleReassign()}
disabled={
saving ||
!reassignMember.trim() ||
reassignMember.trim() === parcel.member_id
}
>
确认指派
</Button>
</div>
)}
</div>
<ParcelStatusPill status={parcel.status} />
</div>
Expand Down
33 changes: 28 additions & 5 deletions bia-admin/app/(admin)/admin/shipping/parcels/intake/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,19 @@ export default function BulkIntakePage() {
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
以下单号对应多个待入库包裹,请到包裹列表手动入库:
<div className="mt-2 font-mono text-xs">
{ambiguous.map((a) => a.code).join("、")}
以下单号对应多个待入库包裹,点击单号到包裹列表手动处理:
<div className="mt-2 flex flex-wrap gap-2 font-mono text-xs">
{ambiguous.map((a) => (
<a
key={a.code}
href={`/admin/shipping/parcels?search=${encodeURIComponent(a.code)}`}
target="_blank"
rel="noreferrer"
className="rounded border px-1.5 py-0.5 hover:bg-muted hover:underline"
>
{a.code} ↗
</a>
))}
</div>
</CardContent>
</Card>
Expand All @@ -293,8 +303,21 @@ export default function BulkIntakePage() {
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 text-sm text-muted-foreground">
没找到「待入库」且单号一致的包裹(可能学生还没填单号,或已入库):
<div className="mt-2 font-mono text-xs">{unmatched.join("、")}</div>
没找到「待入库」且单号一致的包裹(可能学生还没填单号,或已入库)。
点击单号到包裹列表核实,或到「包裹 → 新建」代录 walk-in:
<div className="mt-2 flex flex-wrap gap-2 font-mono text-xs">
{unmatched.map((code) => (
<a
key={code}
href={`/admin/shipping/parcels?search=${encodeURIComponent(code)}`}
target="_blank"
rel="noreferrer"
className="rounded border px-1.5 py-0.5 hover:bg-muted hover:underline"
>
{code} ↗
</a>
))}
</div>
</CardContent>
</Card>
)}
Expand Down
2 changes: 1 addition & 1 deletion bia-admin/app/(admin)/admin/shipping/parcels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface PageProps {
}

function formatDate(value: string): string {
return new Intl.DateTimeFormat("en-US", {
return new Intl.DateTimeFormat("zh-CN", {
month: "short",
day: "numeric",
year: "numeric",
Expand Down
Loading
Loading