forked from a/lifeto-shop
183 lines
6.1 KiB
TypeScript
183 lines
6.1 KiB
TypeScript
|
|
import {
|
|||
|
|
FloatingFocusManager,
|
|||
|
|
FloatingOverlay,
|
|||
|
|
FloatingPortal,
|
|||
|
|
useClick,
|
|||
|
|
useDismiss,
|
|||
|
|
useFloating,
|
|||
|
|
useInteractions,
|
|||
|
|
useRole,
|
|||
|
|
} from '@floating-ui/react'
|
|||
|
|
import { useAtom, useSetAtom } from 'jotai'
|
|||
|
|
import { useState } from 'react'
|
|||
|
|
import {
|
|||
|
|
clearItemSelectionActionAtom,
|
|||
|
|
closeMoveConfirmationAtom,
|
|||
|
|
type MoveItemsResult,
|
|||
|
|
moveConfirmationAtom,
|
|||
|
|
moveSelectedItemsAtom,
|
|||
|
|
} from '@/state/atoms'
|
|||
|
|
|
|||
|
|
export function MoveConfirmationPopup() {
|
|||
|
|
const [confirmationState] = useAtom(moveConfirmationAtom)
|
|||
|
|
const closeConfirmation = useSetAtom(closeMoveConfirmationAtom)
|
|||
|
|
const moveItems = useSetAtom(moveSelectedItemsAtom)
|
|||
|
|
const clearSelection = useSetAtom(clearItemSelectionActionAtom)
|
|||
|
|
const [isMoving, setIsMoving] = useState(false)
|
|||
|
|
const [moveResult, setMoveResult] = useState<MoveItemsResult | null>(null)
|
|||
|
|
|
|||
|
|
const { refs, context } = useFloating({
|
|||
|
|
open: confirmationState.isOpen,
|
|||
|
|
onOpenChange: open => {
|
|||
|
|
if (!open && !isMoving) {
|
|||
|
|
closeConfirmation()
|
|||
|
|
setMoveResult(null)
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const click = useClick(context)
|
|||
|
|
const dismiss = useDismiss(context, {
|
|||
|
|
outsidePressEvent: 'mousedown',
|
|||
|
|
escapeKey: !isMoving,
|
|||
|
|
})
|
|||
|
|
const role = useRole(context)
|
|||
|
|
|
|||
|
|
const { getFloatingProps } = useInteractions([click, dismiss, role])
|
|||
|
|
|
|||
|
|
if (!confirmationState.isOpen) return null
|
|||
|
|
|
|||
|
|
const { selectedItems, sourceCharacter, targetCharacter } = confirmationState
|
|||
|
|
|
|||
|
|
const handleConfirm = async () => {
|
|||
|
|
setIsMoving(true)
|
|||
|
|
try {
|
|||
|
|
const result = await moveItems()
|
|||
|
|
setMoveResult(result)
|
|||
|
|
if (result.failedCount === 0) {
|
|||
|
|
clearSelection()
|
|||
|
|
setTimeout(() => {
|
|||
|
|
closeConfirmation()
|
|||
|
|
setMoveResult(null)
|
|||
|
|
}, 1500)
|
|||
|
|
}
|
|||
|
|
} catch (_error) {
|
|||
|
|
// Error handled in UI
|
|||
|
|
} finally {
|
|||
|
|
setIsMoving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handleCancel = () => {
|
|||
|
|
if (!isMoving) {
|
|||
|
|
closeConfirmation()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const renderItemPreview = () => {
|
|||
|
|
const itemsArray = Array.from(selectedItems.values())
|
|||
|
|
const totalUniqueItems = itemsArray.length
|
|||
|
|
const totalQuantity = itemsArray.reduce((sum, { count }) => sum + count, 0)
|
|||
|
|
|
|||
|
|
if (totalUniqueItems > 5) {
|
|||
|
|
return (
|
|||
|
|
<div className="text-center py-4">
|
|||
|
|
<p className="text-lg font-semibold">Moving {totalUniqueItems} different items</p>
|
|||
|
|
<p className="text-sm text-gray-600">Total quantity: {totalQuantity.toLocaleString()}</p>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
|||
|
|
{itemsArray.map(({ item, count }) => (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
className="flex items-center justify-between px-2 py-1 hover:bg-gray-50 rounded"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<img
|
|||
|
|
src={item.item_image || ''}
|
|||
|
|
alt={item.item_name}
|
|||
|
|
className="w-6 h-6 object-contain"
|
|||
|
|
/>
|
|||
|
|
<span className="text-sm">{item.item_name}</span>
|
|||
|
|
</div>
|
|||
|
|
<span className="text-sm font-medium text-gray-600">×{count.toLocaleString()}</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<FloatingPortal>
|
|||
|
|
<FloatingOverlay className="grid place-items-center bg-black/50 z-50" lockScroll>
|
|||
|
|
<FloatingFocusManager context={context} initialFocus={-1}>
|
|||
|
|
<div
|
|||
|
|
ref={refs.setFloating}
|
|||
|
|
className="bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4"
|
|||
|
|
{...getFloatingProps()}
|
|||
|
|
>
|
|||
|
|
<h2 className="text-xl font-bold mb-4">Confirm Item Movement</h2>
|
|||
|
|
|
|||
|
|
<div className="mb-4 space-y-2">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-sm text-gray-600">From:</span>
|
|||
|
|
<span className="font-medium">{sourceCharacter?.name || 'Unknown'}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-sm text-gray-600">To:</span>
|
|||
|
|
<span className="font-medium">{targetCharacter?.name || 'Unknown'}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="border-t border-b border-gray-200 py-4 mb-4">{renderItemPreview()}</div>
|
|||
|
|
|
|||
|
|
{moveResult && (
|
|||
|
|
<div
|
|||
|
|
className={`mb-4 p-3 rounded ${moveResult.failedCount > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}
|
|||
|
|
>
|
|||
|
|
<p className="text-sm font-medium">
|
|||
|
|
{moveResult.failedCount === 0
|
|||
|
|
? `Successfully moved ${moveResult.successCount} items!`
|
|||
|
|
: `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`}
|
|||
|
|
</p>
|
|||
|
|
{moveResult.errors.length > 0 && (
|
|||
|
|
<div className="mt-2 text-xs text-red-600">
|
|||
|
|
{moveResult.errors.slice(0, 3).map(error => (
|
|||
|
|
<p key={error.itemId}>{error.error}</p>
|
|||
|
|
))}
|
|||
|
|
{moveResult.errors.length > 3 && (
|
|||
|
|
<p>...and {moveResult.errors.length - 3} more errors</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex gap-3 justify-end">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleCancel}
|
|||
|
|
disabled={isMoving}
|
|||
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
Cancel
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleConfirm}
|
|||
|
|
disabled={isMoving || moveResult !== null}
|
|||
|
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
{isMoving ? 'Moving...' : 'Confirm Move'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</FloatingFocusManager>
|
|||
|
|
</FloatingOverlay>
|
|||
|
|
</FloatingPortal>
|
|||
|
|
)
|
|||
|
|
}
|