feat(dn): a page to see and decide a deletion flag
Co-Authored-By: MiniMax Code <noreply@minimax.io>
This commit is contained in:
parent
7c1a7f74fc
commit
84da8eceac
3 changed files with 175 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>;
|
||||
}
|
||||
Loading…
Reference in a new issue