update
This commit is contained in:
+314
-34
@@ -3,14 +3,36 @@ import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('recent');
|
||||
const [sort, setSort] = useState('value');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [maxValue, setMaxValue] = useState('10');
|
||||
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
|
||||
const [settingsBusy, setSettingsBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
const saved = loadInventorySort(user.id);
|
||||
if (saved) setSort(saved);
|
||||
}, [user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
||||
if (user.autoSellThresholdCents != null) {
|
||||
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
||||
}
|
||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
@@ -32,38 +54,98 @@ export default function Dashboard() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
return onInventoryChanged(() => {
|
||||
load().catch(() => {});
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
||||
);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
}
|
||||
return list;
|
||||
}, [inventory, sort]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageItems = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return sorted.slice(start, start + PAGE_SIZE);
|
||||
}, [sorted, currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
|
||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const thresholdCents = useMemo(() => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
||||
return Math.round(credits * 100);
|
||||
}, [maxValue]);
|
||||
|
||||
const belowThreshold = useMemo(() => {
|
||||
if (thresholdCents == null) return [];
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
}, [inventory, thresholdCents]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
() => inventory.filter((inv) => selected.includes(inv.id)),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
||||
const selectedAllFavorite =
|
||||
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
|
||||
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
belowThreshold.reduce(
|
||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
||||
0
|
||||
),
|
||||
[belowThreshold]
|
||||
);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
if (!selected.length) return;
|
||||
const sellIds = async (ids) => {
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.sellInventory(selected);
|
||||
const data = await api.sellInventory(ids);
|
||||
patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
setSelected((prev) => prev.filter((id) => !ids.includes(id)));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -72,6 +154,71 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
|
||||
if (!ids.length) return;
|
||||
await sellIds(ids);
|
||||
};
|
||||
|
||||
const setFavorite = async (favorite) => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.setInventoryFavorite(selected, favorite);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sellBelow = async () => {
|
||||
const ids = belowThreshold.map((inv) => inv.id);
|
||||
if (!ids.length || thresholdCents == null) return;
|
||||
const favSkipped = inventory.filter(
|
||||
(inv) =>
|
||||
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
).length;
|
||||
const ok = window.confirm(
|
||||
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
|
||||
(favSkipped ? ` (${favSkipped} favorite item${favSkipped === 1 ? '' : 's'} excluded)` : '')
|
||||
);
|
||||
if (!ok) return;
|
||||
await sellIds(ids);
|
||||
};
|
||||
|
||||
const saveAutoSell = async (enabled, thresholdCredits) => {
|
||||
setSettingsBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.updateAutoSell({ enabled, thresholdCredits });
|
||||
patchUser(data.user);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSettingsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAutoSellToggle = async (enabled) => {
|
||||
setAutoSellEnabled(enabled);
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
await saveAutoSell(enabled, credits);
|
||||
};
|
||||
|
||||
const onThresholdBlur = async () => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
const savedCents = user?.autoSellThresholdCents ?? 1000;
|
||||
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
return;
|
||||
}
|
||||
await saveAutoSell(autoSellEnabled, credits);
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
@@ -84,31 +231,44 @@ export default function Dashboard() {
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
</div>
|
||||
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-toolbar">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'recent' ? 'active' : ''}
|
||||
onClick={() => setSort('recent')}
|
||||
>
|
||||
Most recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'value' ? 'active' : ''}
|
||||
onClick={() => setSort('value')}
|
||||
>
|
||||
Highest value
|
||||
</button>
|
||||
<div className="inv-toolbar-left">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'value' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setSort('value');
|
||||
saveInventorySort(user.id, 'value');
|
||||
}}
|
||||
>
|
||||
Highest value
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'recent' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
setSort('recent');
|
||||
saveInventorySort(user.id, 'recent');
|
||||
}}
|
||||
>
|
||||
Most recent
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length > 0 && (
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span className="muted">
|
||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
||||
{selectedHasFavorite ? ' · includes favorites' : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -118,7 +278,33 @@ export default function Dashboard() {
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={sell} disabled={busy}>
|
||||
{!selectedAllFavorite && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setFavorite(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
Favorite
|
||||
</button>
|
||||
)}
|
||||
{selectedItems.some((inv) => inv.favorite) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setFavorite(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
Unfavorite
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={selectedHasFavorite ? 'Favorites cannot be sold' : undefined}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</>
|
||||
@@ -126,6 +312,74 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-bulk-sell">
|
||||
{inventory.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || belowThreshold.length === 0}
|
||||
onClick={sellBelow}
|
||||
>
|
||||
Sell below
|
||||
</button>
|
||||
<label className="inv-bulk-label">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={maxValue}
|
||||
disabled={busy || settingsBusy}
|
||||
onChange={(e) => setMaxValue(e.target.value)}
|
||||
onBlur={onThresholdBlur}
|
||||
aria-label="Maximum market value in credits"
|
||||
/>
|
||||
<span className="muted">cr</span>
|
||||
</label>
|
||||
<span className="muted inv-bulk-preview">
|
||||
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
||||
{formatCredits(belowValue)} cr
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inventory.length === 0 && (
|
||||
<label className="inv-bulk-label">
|
||||
Threshold
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={maxValue}
|
||||
disabled={busy || settingsBusy}
|
||||
onChange={(e) => setMaxValue(e.target.value)}
|
||||
onBlur={onThresholdBlur}
|
||||
aria-label="Auto-sell threshold in credits"
|
||||
/>
|
||||
<span className="muted">cr</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="inv-auto-sell-box">
|
||||
<label className="inv-auto-sell-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoSellEnabled}
|
||||
disabled={busy || settingsBusy}
|
||||
onChange={(e) => onAutoSellToggle(e.target.checked)}
|
||||
/>
|
||||
<span>Auto-sell</span>
|
||||
</label>
|
||||
<span className="inv-auto-sell-at muted">
|
||||
at{' '}
|
||||
{formatCredits(
|
||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
||||
? Math.round(Number(maxValue) * 100)
|
||||
: 0
|
||||
)}{' '}
|
||||
cr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
@@ -136,16 +390,42 @@ export default function Dashboard() {
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{sorted.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid">
|
||||
{pageItems.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="inv-pager">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="muted">
|
||||
Page {currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user