feat(dn): a page to see and decide a deletion flag

Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
DEls 2026-10-09 21:56:07 +02:00
parent 7c1a7f74fc
commit 84da8eceac
3 changed files with 175 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import {expect,it,vi} from 'vitest';
import {renderToStaticMarkup} from 'react-dom/server';
import * as React from 'react';
vi.mock('next/navigation',()=>({useRouter:()=>({push:vi.fn(),refresh:vi.fn()})}));
import {FlagDecision} from './flag-decision';
const render=(props:Partial<React.ComponentProps<typeof FlagDecision>>={})=>renderToStaticMarkup(<FlagDecision collection="supplier_document_aliases" recordId="aliasaaaaaaaaaa" canDecide deleteBlock={null} {...props}/>);
const text=(html:string)=>html.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim();
/** The opening tag of the button reading `label`, so its own `disabled` can be read. */
const buttonTag=(html:string,label:string):string=>html.split('<button').slice(1)
.map(part=>({open:part.slice(0,part.indexOf('>')),label:text(part.slice(part.indexOf('>'),part.indexOf('</button>')))}))
.find(button=>button.label.includes(label))?.open??'';
it('offers both decisions when the record can be deleted',()=>{
const html=render();
expect(text(html)).toContain('Keep');
expect(text(html)).toContain('Delete');
expect(html).toContain('<textarea');
expect(buttonTag(html,'Keep')).not.toContain('disabled=""');
expect(buttonTag(html,'Delete')).not.toContain('disabled=""');
expect(text(html)).not.toContain('never deleted');
});
it('disables Delete and says why when the rules block it',()=>{
const html=render({deleteBlock:'This is delivery evidence and is never deleted. Keep it, or set it aside or move the receipt from its document page.'});
expect(text(html)).toContain('This is delivery evidence and is never deleted.');
// Keep stays open: a blocked Delete still lets a person record why it stands.
expect(text(html)).toContain('Keep');
expect(buttonTag(html,'Delete')).toContain('disabled=""');
expect(buttonTag(html,'Keep')).not.toContain('disabled=""');
});
it('shows no buttons to somebody who may not decide this flag',()=>{
const html=render({canDecide:false});
expect(html).not.toContain('<button');
expect(text(html)).toContain('Only an admin or a site manager of the sites involved can decide this flag.');
// The reason box would be pointless without the buttons, so it goes too.
expect(html).not.toContain('<textarea');
});

View file

@ -0,0 +1,61 @@
"use client";
import * as React from 'react';
import {useRouter} from 'next/navigation';
import {Button} from '@/components/ui/button';
const INBOX='/dashboard/financial/delivery-notes/inbox';
/**
* Delete or Keep, for the person who can decide this flag.
*
* Both buttons ask for the same reason: a decision nobody can explain is no
* decision at all. Delete is the destructive one and is hidden behind a confirm,
* because the record goes for good — the copy that survives it is a journal row
* for an administrator, not a restore button.
*
* A blocked Delete is shown disabled with its reason rather than hidden, so the
* person learns why the flag cannot go instead of wondering where Delete went.
*/
export function FlagDecision({collection,recordId,canDecide,deleteBlock}:{collection:string;recordId:string;canDecide:boolean;deleteBlock:string|null}){
const router=useRouter();
const [reason,setReason]=React.useState('');
const [busy,setBusy]=React.useState(false);
const [error,setError]=React.useState('');
if(!canDecide)return <p className="text-sm text-muted-foreground">Only an admin or a site manager of the sites involved can decide this flag.</p>;
async function decide(action:'delete'|'keep'){
if(busy)return;
if(action==='delete'&&!window.confirm('Delete this record? A copy is saved so it can be restored.'))return;
setBusy(true);setError('');
try{
const response=await fetch(`/api/deletion-flags/${collection}/${recordId}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,reason})});
if(!response.ok){
const body=await response.json().catch(()=>({}));
setError(String(body.detail||body.error||`Could not save (${response.status}).`));
return;
}
router.push(INBOX);
}catch{
setError('Could not save. Check the connection and try again.');
}finally{
setBusy(false);
}
}
return <div className="space-y-3">
<label className="block text-sm">
<span className="font-medium">Why</span>
<textarea aria-label="Why you decided this" required value={reason} onChange={event=>setReason(event.target.value)}
placeholder="One line a colleague would understand later." rows={2}
className="mt-1 w-full rounded-md border border-input bg-background p-2 text-sm"/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" disabled={busy} onClick={()=>void decide('keep')}>{busy?'Saving…':'Keep'}</Button>
<Button variant="destructive" disabled={busy||!!deleteBlock} onClick={()=>void decide('delete')}>Delete</Button>
</div>
{deleteBlock&&<p className="text-sm text-amber-700 dark:text-amber-300">{deleteBlock}</p>}
{error&&<p role="alert" className="text-sm text-red-700 dark:text-red-400">{error}</p>}
</div>;
}

View file

@ -0,0 +1,73 @@
import Link from 'next/link';
import {Badge} from '@/components/ui/badge';
import {Card,CardContent,CardHeader} from '@/components/ui/card';
import {Topbar} from '@/components/layout/sidebar';
import {pbAdmin} from '@/lib/pb';
import {requirePageUser} from '@/lib/auth/guard';
import {canDecide,deletionBlock,describeFlag} from '@/lib/purchasing/deletion-decisions';
import {FlagDecision as FlagDecisionForm} from './flag-decision';
export const dynamic='force-dynamic';
const INBOX='/dashboard/financial/delivery-notes/inbox';
/**
* One deletion flag, in words, with the decision underneath it.
*
* Everything a person needs is on this page — what the record is, why it was
* flagged, and what else it touches — so nobody has to go and read the raw
* record to decide whether it should go. The agent that raised it cannot delete
* it; this page and its two buttons are the whole of what a person can do.
*/
export default async function FlagPage({params}:{params:Promise<{collection:string;recordId:string}>}){
const user=await requirePageUser('financial');
const {collection,recordId}=await params;
const pb=await pbAdmin();
const detail=await describeFlag(pb,collection,recordId);
if(!detail){
return <>
<Topbar title="Deletion flag" subtitle="This flag is no longer waiting for anyone."/>
<div className="space-y-4 p-4 sm:p-6">
<p className="text-sm">This flag has already been decided.</p>
<Link href={INBOX} className="text-sm underline">Back to the inbox</Link>
</div>
</>;
}
const record=await pb.collection(collection).getOne(recordId).catch(()=>null) as unknown as Record<string,unknown>|null;
const blocked=record?await deletionBlock(pb,collection,record):null;
const allowed=record?await canDecide(pb,user,collection,record):false;
return <>
<Topbar title="Deletion flag" subtitle="Read what was flagged, then keep it or delete it."/>
<div className="space-y-4 p-4 sm:p-6">
<Card className="border-2 border-amber-500">
<CardHeader className="p-4">
<Badge variant="warning" className="w-fit">{detail.kind}</Badge>
<h1 className="text-lg font-semibold leading-snug">{detail.headline}</h1>
</CardHeader>
<CardContent className="space-y-4 p-4 pt-0">
<section className="space-y-1">
<h2 className="text-sm font-medium">Why it was flagged</h2>
{detail.reasons.map((reason,index)=><p key={index} className="text-sm text-muted-foreground">{reason} <span className="whitespace-nowrap">— Agent Foreman</span></p>)}
</section>
<section className="space-y-1">
<h2 className="text-sm font-medium">What it is</h2>
<dl className="grid grid-cols-1 gap-x-6 gap-y-1 text-sm sm:grid-cols-[10rem_1fr]">
{detail.fields.map(field=><div key={field.label} className="contents"><dt className="text-muted-foreground">{field.label}</dt><dd>{field.value}</dd></div>)}
</dl>
</section>
<section className="space-y-1">
<h2 className="text-sm font-medium">What it affects</h2>
{detail.affects.length===0
?<p className="text-sm text-muted-foreground">Nothing else points at this record.</p>
:<ul className="space-y-1 text-sm">{detail.affects.map(link=><li key={link.href}><Link href={link.href} className="underline">{link.label}</Link></li>)}</ul>}
</section>
<FlagDecisionForm collection={collection} recordId={recordId} canDecide={allowed} deleteBlock={blocked}/>
</CardContent>
</Card>
<Link href={INBOX} className="inline-block text-sm underline">Back to the inbox</Link>
</div>
</>;
}