deckhearth/components/BatchEditModal.js
Randall Stillwell 70806d81ec
Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
feat(admin): add batch selection + batch edit (Phase 2)
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection
- BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview
- API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging
- Hook: selection state (Set), select-all, batch edit workflow
- Table: checkbox column, row highlighting, select-all header
- Build: all routes compile, lint clean (pre-existing only), 342 tests pass
2026-09-18 14:57:14 -05:00

281 lines
No EOL
9.9 KiB
JavaScript

import { useState } from 'react';
import { GlassSurface, Button, Input, Modal } from './ui';
const RARITY_OPTIONS = [
{ value: 'common', label: 'Common' },
{ value: 'uncommon', label: 'Uncommon' },
{ value: 'rare', label: 'Rare' },
{ value: 'mythic', label: 'Mythic' },
{ value: 'special', label: 'Special' },
{ value: 'bonus', label: 'Bonus' }
];
const GAME_OPTIONS = [
{ value: 'mtg', label: 'Magic: The Gathering' },
{ value: 'pokemon', label: 'Pokémon TCG' },
{ value: 'lorcana', label: 'Disney Lorcana' }
];
const LAYOUT_OPTIONS = [
{ value: 'normal', label: 'Normal' },
{ value: 'transform', label: 'Transform' },
{ value: 'modal_dfc', label: 'Modal DFC' },
{ value: 'meld', label: 'Meld' },
{ value: 'split', label: 'Split' },
{ value: 'flip', label: 'Flip' },
{ value: 'leveler', label: 'Leveler' },
{ value: 'saga', label: 'Saga' },
{ value: 'class', label: 'Class' },
{ value: 'adventure', label: 'Adventure' },
{ value: 'planar', label: 'Planar' },
{ value: 'scheme', label: 'Scheme' },
{ value: 'vanguard', label: 'Vanguard' },
{ value: 'token', label: 'Token' },
{ value: 'emblem', label: 'Emblem' },
{ value: 'augment', label: 'Augment' },
{ value: 'host', label: 'Host' },
{ value: 'art_series', label: 'Art Series' }
];
export default function BatchEditModal({
isOpen,
onClose,
onSubmit,
loading,
error,
selectedCount
}) {
const [formData, setFormData] = useState({
rarity: '',
verified: '',
game: '',
mana_cost: '',
cmc: '',
card_type: '',
edhrec_rank: '',
layout: '',
released_at: '',
reserved: '',
reprint: ''
});
const [dirtyFields, setDirtyFields] = useState(new Set());
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
setDirtyFields(prev => new Set([...prev, field]));
};
const handleBlur = (field) => {
// Keep track of touched fields
};
const getFieldsToSubmit = () => {
const updates = {};
dirtyFields.forEach(field => {
const value = formData[field];
if (value === '') return;
if (field === 'verified' || field === 'reserved' || field === 'reprint') {
updates[field] = value === 'true';
} else if (field === 'cmc' || field === 'edhrec_rank') {
updates[field] = value === '' ? null : parseInt(value, 10);
} else {
updates[field] = value;
}
});
return updates;
};
const hasChanges = dirtyFields.size > 0;
const handleSubmit = (e) => {
e.preventDefault();
const updates = getFieldsToSubmit();
if (Object.keys(updates).length > 0) {
onSubmit(updates);
}
};
if (!isOpen) return null;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Batch Edit ${selectedCount} Card${selectedCount !== 1 ? 's' : ''}`}
size="lg"
>
<form onSubmit={handleSubmit}>
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
<div className="mb-4 p-3 rounded-xl text-sm" style={{
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border)'
}}>
<strong style={{ color: 'var(--text-primary)' }}>Fields to update:</strong>
<ul className="mt-2 space-y-1">
{Array.from(dirtyFields).map(field => (
<li key={field} style={{ color: 'var(--text-secondary)' }}>
{field}: {field === 'verified' || field === 'reserved' || field === 'reprint'
? (formData[field] === 'true' ? 'Yes' : 'No')
: formData[field] || '(cleared)'}
</li>
))}
{dirtyFields.size === 0 && (
<li style={{ color: 'var(--text-tertiary)' }}>No fields changed yet</li>
)}
</ul>
</div>
<GlassSurface tint="low" rim="none" className="p-4 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Rarity"
type="select"
value={formData.rarity}
onChange={(e) => handleChange('rarity', e.target.value)}
options={RARITY_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select rarity"
dirty={dirtyFields.has('rarity')}
/>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer w-full">
<span className="w-1/3 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Verified</span>
<select
value={formData.verified}
onChange={(e) => handleChange('verified', e.target.value)}
className="glass-input flex-1 rounded-xl px-4 py-2"
style={{
border: dirtyFields.has('verified') ? '1px solid var(--accent-ember)' : '1px solid var(--border)',
color: 'var(--text-primary)',
backgroundColor: 'var(--bg-tertiary)'
}}
>
<option value="">—</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</label>
</div>
<Input
label="Game"
type="select"
value={formData.game}
onChange={(e) => handleChange('game', e.target.value)}
options={GAME_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select game"
dirty={dirtyFields.has('game')}
/>
<Input
label="Layout"
type="select"
value={formData.layout}
onChange={(e) => handleChange('layout', e.target.value)}
options={LAYOUT_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
placeholder="Select layout"
dirty={dirtyFields.has('layout')}
/>
<Input
label="Mana Cost"
value={formData.mana_cost}
onChange={(e) => handleChange('mana_cost', e.target.value)}
placeholder="{2}{W}{W}"
dirty={dirtyFields.has('mana_cost')}
/>
<Input
label="CMC"
type="number"
min="0"
max="20"
value={formData.cmc}
onChange={(e) => handleChange('cmc', e.target.value)}
placeholder="3"
dirty={dirtyFields.has('cmc')}
/>
<Input
label="Card Type"
value={formData.card_type}
onChange={(e) => handleChange('card_type', e.target.value)}
placeholder="Creature — Human Wizard"
dirty={dirtyFields.has('card_type')}
/>
<Input
label="EDHREC Rank"
type="number"
min="0"
value={formData.edhrec_rank}
onChange={(e) => handleChange('edhrec_rank', e.target.value)}
placeholder="12345"
dirty={dirtyFields.has('edhrec_rank')}
/>
<Input
label="Released At"
type="date"
value={formData.released_at}
onChange={(e) => handleChange('released_at', e.target.value)}
dirty={dirtyFields.has('released_at')}
/>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reserved === 'true'}
onChange={(e) => handleChange('reserved', e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-2"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)'
}}
/>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Reserved List
</span>
</label>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.reprint === 'true'}
onChange={(e) => handleChange('reprint', e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-2"
style={{
borderColor: 'var(--border)',
accentColor: 'var(--accent-ember)'
}}
/>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
Reprint
</span>
</label>
</div>
</div>
</GlassSurface>
{error && (
<div className="p-3 rounded-xl text-sm" style={{
backgroundColor: 'rgba(220, 38, 38, 0.1)',
border: '1px solid rgba(220, 38, 38, 0.3)',
color: '#dc2626'
}}>
{error}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3 border-t pt-4" style={{ borderColor: 'var(--border)' }}>
<Button variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={loading || !hasChanges}
loading={loading}
>
Apply to {selectedCount} Card{selectedCount !== 1 ? 's' : ''}
</Button>
</div>
</form>
</Modal>
);
}