131 lines
4.2 KiB
TypeScript
131 lines
4.2 KiB
TypeScript
import * as React from 'react'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { AdminActionDialog, type StatusTone } from '@/components/admin'
|
|
|
|
export type BatchActionId = 'hide' | 'delete' | 'restore' | string
|
|
|
|
export interface BatchActionConfig {
|
|
id: BatchActionId
|
|
label: string
|
|
/** When true, the confirmation requires a note before submit. */
|
|
destructive?: boolean
|
|
tone?: StatusTone
|
|
confirmTitle: string
|
|
confirmDescription: string
|
|
confirmLabel?: string
|
|
}
|
|
|
|
interface BatchOperationsToolbarProps {
|
|
selectedCount: number
|
|
totalCount?: number
|
|
onSelectAll?: () => void
|
|
onClearSelection?: () => void
|
|
actions: BatchActionConfig[]
|
|
onAction: (action: BatchActionConfig, remark: string) => void
|
|
isLoading?: boolean
|
|
pendingAction?: BatchActionId | null
|
|
}
|
|
|
|
export function BatchOperationsToolbar({
|
|
selectedCount,
|
|
totalCount,
|
|
onSelectAll,
|
|
onClearSelection,
|
|
actions,
|
|
onAction,
|
|
isLoading = false,
|
|
pendingAction = null,
|
|
}: BatchOperationsToolbarProps) {
|
|
const [pending, setPending] = React.useState<BatchActionConfig | null>(null)
|
|
const [remark, setRemark] = React.useState('')
|
|
|
|
if (selectedCount === 0) return null
|
|
|
|
function handleConfirm() {
|
|
if (!pending) return
|
|
if (pending.destructive && !remark.trim()) {
|
|
return
|
|
}
|
|
onAction(pending, remark.trim())
|
|
setPending(null)
|
|
setRemark('')
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className='flex flex-wrap items-center gap-3 rounded-md border border-primary/30 bg-primary/5 px-3 py-2'>
|
|
<Badge variant='default' className='gap-1'>
|
|
Selected {selectedCount}
|
|
{typeof totalCount === 'number' ? ` / ${totalCount}` : ''}
|
|
</Badge>
|
|
{onSelectAll && (
|
|
<Button variant='ghost' size='sm' onClick={onSelectAll}>
|
|
Select current page
|
|
</Button>
|
|
)}
|
|
{onClearSelection && (
|
|
<Button variant='ghost' size='sm' onClick={onClearSelection}>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
<div className='ml-auto flex flex-wrap items-center gap-2'>
|
|
{actions.map((action) => (
|
|
<Button
|
|
key={action.id}
|
|
size='sm'
|
|
variant={action.destructive ? 'destructive' : 'outline'}
|
|
disabled={isLoading}
|
|
onClick={() => {
|
|
setRemark('')
|
|
setPending(action)
|
|
}}
|
|
>
|
|
{action.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<AdminActionDialog
|
|
open={pending !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setPending(null)
|
|
setRemark('')
|
|
}
|
|
}}
|
|
tone={pending?.tone ?? (pending?.destructive ? 'danger' : 'info')}
|
|
title={pending?.confirmTitle ?? 'Confirm bulk action'}
|
|
description={pending?.confirmDescription ?? ''}
|
|
confirmLabel={pending?.confirmLabel ?? 'Confirm'}
|
|
isLoading={isLoading && pendingAction === pending?.id}
|
|
onConfirm={handleConfirm}
|
|
>
|
|
{pending?.destructive ? (
|
|
<div className='space-y-2'>
|
|
<label className='text-sm font-medium'>Note (required)</label>
|
|
<textarea
|
|
className='min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
|
placeholder='Add a short reason for this action'
|
|
value={remark}
|
|
onChange={(e) => setRemark(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className='space-y-2'>
|
|
<label className='text-sm font-medium'>Note (optional)</label>
|
|
<textarea
|
|
className='min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
|
placeholder='Add a short note'
|
|
value={remark}
|
|
onChange={(e) => setRemark(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</AdminActionDialog>
|
|
</>
|
|
)
|
|
}
|