feat(intake): add product abandon action to the product list
the product list rows gain an abandon action next to edit/copy/delete for admins and product owners (operations brand / spu / appearance / structure / purchase / merchandiser / planner / space owners matched by name); abandoning sets projectStatus to rejected which makes every workspace read-only and the existing daily todo push already skips rejected products, so abandoned products drop out of daily pushes automatically; the server enforces the same permission via a new POST abandon endpoint. Abandoned rows keep the restore action to come back.
This commit is contained in:
parent
5754172787
commit
df9c0ad67f
@ -0,0 +1,32 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async (ctx) => {
|
||||
const { id } = await context.params
|
||||
const decodedId = decodeURIComponent(id)
|
||||
|
||||
try {
|
||||
const result = await intakeNonIntegratedProductService.abandonProduct(
|
||||
decodedId,
|
||||
ctx.user,
|
||||
)
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{ requireAuth: true },
|
||||
)
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Ban, Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import Button from "@/components/ui/Button";
|
||||
import {
|
||||
CopyProductDialog,
|
||||
@ -62,8 +62,11 @@ import { getDocumentWorkspaceCountByCategory } from "@/app/intake/product-develo
|
||||
import { ProductDevelopmentCategoryFilterField } from "@/app/intake/product-development/ProductDevelopmentCategoryFilterField";
|
||||
import {
|
||||
displayPerson,
|
||||
getCurrentUserResponsibleOwnerValues,
|
||||
getNonIntegratedListPeople,
|
||||
isAdminUser,
|
||||
isCurrentUserResponsibleForNonIntegratedProduct,
|
||||
matchesCurrentUserName,
|
||||
productMatchesText,
|
||||
type NonIntegratedListPermissionUser,
|
||||
} from "@/app/intake/product-development/nonIntegratedProductListLogic";
|
||||
@ -113,9 +116,23 @@ function createRestoredNonIntegratedProduct(
|
||||
};
|
||||
}
|
||||
|
||||
function createAbandonedNonIntegratedProduct(
|
||||
product: NonIntegratedProductRecord,
|
||||
): NonIntegratedProductRecord {
|
||||
return {
|
||||
...product,
|
||||
projectStatus: "rejected",
|
||||
openingReference: {
|
||||
...product.openingReference,
|
||||
currentStage: "废弃不开",
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const PENDING_PAGE_SIZE = 50;
|
||||
const TABLE_ACTION_BUTTON_BASE =
|
||||
"!h-8 !min-h-8 !w-8 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-7 max-md:!min-h-7 max-md:!w-7 max-md:!min-w-7";
|
||||
"!h-7 !min-h-7 !w-7 !min-w-7 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-6 max-md:!min-h-6 max-md:!w-6 max-md:!min-w-6";
|
||||
const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`;
|
||||
const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`;
|
||||
const INTAKE_PRIMARY_BUTTON_CLASS =
|
||||
@ -622,6 +639,50 @@ export function NonIntegratedPendingDetail({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAbandon(product: NonIntegratedProductRecord) {
|
||||
const productLabel = product.spu?.trim() || "当前产品";
|
||||
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认废弃「${productLabel}」吗?\n废弃后资料将变为只读,不能继续编辑,且不再触发每日推送提醒。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldUseSharedNonIntegratedApi) {
|
||||
const response = await fetch(
|
||||
`/api/intake/non-integrated/${encodeURIComponent(product.id)}/abandon`,
|
||||
{ method: "POST", credentials: "same-origin" },
|
||||
);
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
success?: boolean;
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
if (!response.ok || !payload?.success) {
|
||||
throw new Error(payload?.error?.message || "废弃失败,请稍后再试");
|
||||
}
|
||||
} else {
|
||||
saveNonIntegratedProduct(
|
||||
createAbandonedNonIntegratedProduct(product),
|
||||
);
|
||||
}
|
||||
|
||||
const nextProducts = products.map((currentProduct) =>
|
||||
currentProduct.id === product.id
|
||||
? { ...currentProduct, projectStatus: "rejected" }
|
||||
: currentProduct,
|
||||
) as NonIntegratedProductRecord[];
|
||||
setProducts(nextProducts);
|
||||
onProductsChange?.(nextProducts);
|
||||
} catch (error) {
|
||||
window.alert(
|
||||
error instanceof Error ? error.message : "废弃失败,请稍后再试",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailableCopyBrands(product: NonIntegratedProductRecord) {
|
||||
const configuredBrands = Array.from(
|
||||
new Set([
|
||||
@ -1439,18 +1500,18 @@ export function NonIntegratedPendingDetail({
|
||||
>
|
||||
<thead className="bg-apple-bg-primary text-apple-label-secondary">
|
||||
<tr className="border-b border-apple-separator">
|
||||
<th className={`min-w-[56px] ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
<th className={`min-w-[56px] sticky left-0 z-30 ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
序号
|
||||
</th>
|
||||
<th className={`min-w-[96px] ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
<th className={`min-w-[96px] sticky left-[56px] z-30 ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
成品预览图
|
||||
</th>
|
||||
{isSeriesDevelopmentList ? (
|
||||
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
<th className={`min-w-[144px] sticky left-[152px] z-30 ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
系列名称
|
||||
</th>
|
||||
) : null}
|
||||
<th className={`min-w-[144px] ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
<th className={`min-w-[144px] sticky ${isSeriesDevelopmentList ? "left-[296px]" : "left-[152px]"} z-30 ${TABLE_HEADER_CELL_CLASS}`}>
|
||||
<SpuLabel />
|
||||
</th>
|
||||
{showOpeningProductName ? (
|
||||
@ -1525,6 +1586,15 @@ export function NonIntegratedPendingDetail({
|
||||
getDocumentWorkspaceCountByCategory(product);
|
||||
const isFlowAbandoned =
|
||||
product.projectStatus === "rejected";
|
||||
const canAbandonCurrentRow = (
|
||||
row: NonIntegratedProductRecord,
|
||||
) =>
|
||||
row.projectStatus !== "rejected" &&
|
||||
(isAdminUser(currentUser) ||
|
||||
isCurrentUserResponsibleForNonIntegratedProduct(
|
||||
row,
|
||||
currentUser,
|
||||
));
|
||||
const people = getNonIntegratedListPeople(product);
|
||||
const canEditProduct = canEditNonIntegratedProduct(
|
||||
product,
|
||||
@ -1553,12 +1623,12 @@ export function NonIntegratedPendingDetail({
|
||||
key={product.id}
|
||||
className="border-b border-apple-separator"
|
||||
>
|
||||
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-secondary whitespace-nowrap">
|
||||
<td className="sticky left-0 z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-secondary whitespace-nowrap">
|
||||
{pageInfo?.sequenceById?.[product.id] ??
|
||||
orderedFilteredProducts.sequenceById[product.id] ??
|
||||
(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1}
|
||||
</td>
|
||||
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 whitespace-nowrap">
|
||||
<td className="sticky left-[56px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 whitespace-nowrap">
|
||||
{productPreviewImage ? (
|
||||
<HoverImagePreview
|
||||
src={productPreviewImage}
|
||||
@ -1597,11 +1667,11 @@ export function NonIntegratedPendingDetail({
|
||||
)}
|
||||
</td>
|
||||
{isSeriesDevelopmentList ? (
|
||||
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
|
||||
<td className="sticky left-[152px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
|
||||
{seriesDisplayName}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
|
||||
<td className="sticky left-[152px] z-20 bg-apple-bg-primary px-3 py-3 max-md:px-2 max-md:py-1.5 text-apple-label-primary whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-apple-md px-1 py-0.5 text-left text-apple-blue transition hover:bg-apple-blue/10"
|
||||
@ -1721,6 +1791,24 @@ export function NonIntegratedPendingDetail({
|
||||
<span className="sr-only">删除</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!isFlowAbandoned &&
|
||||
canEditProduct &&
|
||||
canAbandonCurrentRow(product) ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
size="small"
|
||||
aria-label="废弃"
|
||||
title="废弃"
|
||||
className={TABLE_ACTION_BUTTON_SECONDARY}
|
||||
onClick={() => handleAbandon(product)}
|
||||
>
|
||||
<Ban
|
||||
className="h-4 w-4 max-md:h-3.5 max-md:w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">废弃</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{isFlowAbandoned && canEditProduct ? (
|
||||
<Button
|
||||
variant="plain"
|
||||
|
||||
@ -7569,6 +7569,72 @@ class IntakeNonIntegratedProductService {
|
||||
await recordIntakeProductDeleted({ product: existingRecord, user })
|
||||
}
|
||||
|
||||
async abandonProduct(id: string, user: AuthUser) {
|
||||
const existingRecord = await this.repository.findActiveById(id)
|
||||
if (!existingRecord) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '非集成产品不存在')
|
||||
}
|
||||
|
||||
const sourcePayload = isRecord(existingRecord.payload)
|
||||
? existingRecord.payload
|
||||
: {}
|
||||
if (
|
||||
String(
|
||||
(sourcePayload as Record<string, unknown>).projectStatus ?? '',
|
||||
).trim() === 'rejected'
|
||||
) {
|
||||
return { abandoned: true }
|
||||
}
|
||||
|
||||
const isAdmin =
|
||||
user.role === 'ADMIN' || user.role === 'SUPER_ADMIN'
|
||||
if (!isAdmin) {
|
||||
const ownerNames = [
|
||||
'operationsBrandOwner',
|
||||
'spuOwner',
|
||||
'appearanceDesigner',
|
||||
'structureDesigner',
|
||||
'purchaseOwner',
|
||||
'merchandiserOwner',
|
||||
'productPlanner',
|
||||
'spaceDesigner',
|
||||
]
|
||||
.map((key) =>
|
||||
String(
|
||||
(sourcePayload as Record<string, unknown>)[key] ?? '',
|
||||
)
|
||||
.trim()
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase(),
|
||||
)
|
||||
.filter((value) => value.length > 0)
|
||||
const userName = String(user.name ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase()
|
||||
const responsible =
|
||||
userName.length > 0 && ownerNames.includes(userName)
|
||||
if (!responsible) {
|
||||
throw new AppError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'仅管理员或产品负责人可废弃该产品',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date().toISOString()
|
||||
await this.repository.update(id, {
|
||||
payload: toJsonValue({
|
||||
...sourcePayload,
|
||||
projectStatus: 'rejected',
|
||||
updatedAt,
|
||||
}),
|
||||
updatedBy: user.id,
|
||||
} as Prisma.IntakeNonIntegratedProductUpdateInput)
|
||||
// 同步结构化列表使用的状态派生(payload.projectStatus 为唯一事实来源)
|
||||
return { abandoned: true }
|
||||
}
|
||||
|
||||
async attachUploadFile(
|
||||
productId: string,
|
||||
input: AttachUploadFileInput,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user