Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 32s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m16s
The items table is shared between both catalogs, but its four mutating actions were normal-only: moving, reordering, creating and updating a Builder Club offer wrote to catalog_items, so a BC edit either landed in the wrong catalog or hit an unknown column. Pass the catalog from the table through the actions and let the server resolve it. BC rows have no price, points or currency column, so the BC commands strip those fields instead of rejecting them. Moving and reordering now share one command that locks the category and writes the table for the same catalog, and BC writes revalidate the BC route.
2469 lines
74 KiB
TypeScript
2469 lines
74 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
type DragEndEvent,
|
|
DragOverlay,
|
|
type DragStartEvent,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
ArrowRightLeft,
|
|
Copy,
|
|
DollarSign,
|
|
GripVertical,
|
|
LayoutGrid,
|
|
Loader2,
|
|
MoveRight,
|
|
Pencil,
|
|
Plus,
|
|
RotateCcw,
|
|
Rows3,
|
|
Save,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
createCatalogItem,
|
|
deleteCatalogItems,
|
|
moveCatalogItems,
|
|
reorderCatalogItems,
|
|
restoreDeletedCatalogItems,
|
|
updateCatalogItem,
|
|
} from "@/actions/catalog-items";
|
|
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
|
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
|
|
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
|
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
|
import { SearchInput } from "@/components/admin/search-input";
|
|
import {
|
|
CurrencyIcon,
|
|
currencyKindFromPointsType,
|
|
} from "@/components/shared/currency-icon";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog";
|
|
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
|
import {
|
|
discoverOffers,
|
|
OFFER_FILTERS,
|
|
type OfferFilter,
|
|
} from "@/features/catalog/domain/offer-discovery";
|
|
import { useServerAction } from "@/hooks/use-server-action";
|
|
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
|
import { cn } from "@/lib/utils";
|
|
import { AddItemFormFields } from "./add-item-form-fields";
|
|
import {
|
|
InteractionTypeCombobox,
|
|
MovePageCombobox,
|
|
ToggleField,
|
|
} from "./field-helpers";
|
|
import { FurniIcon } from "./furni-icon";
|
|
import { RecentDeletionsPanel } from "./recent-deletions-panel";
|
|
import { SongPicker } from "./song-picker";
|
|
import {
|
|
type BaseItemData,
|
|
type CatalogItemData,
|
|
DEFAULT_NEW_ITEM,
|
|
getPointsLabel,
|
|
type InlineEdit,
|
|
POINTS_TYPES,
|
|
} from "./types";
|
|
|
|
export type { BaseItemData, CatalogItemData };
|
|
|
|
// ── Sortable row wrapper (dnd-kit) ─────────────────────────────
|
|
function SortableRow({
|
|
id,
|
|
className,
|
|
children,
|
|
}: {
|
|
id: number;
|
|
className?: string;
|
|
children: (props: {
|
|
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
|
|
}) => React.ReactNode;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id,
|
|
});
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.35 : 1,
|
|
};
|
|
const dragHandleProps = {
|
|
...attributes,
|
|
...listeners,
|
|
} as unknown as React.HTMLAttributes<HTMLDivElement>;
|
|
return (
|
|
<TableRow
|
|
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
|
|
style={style}
|
|
className={className}
|
|
>
|
|
{children({ dragHandleProps })}
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
interface CatalogItemsTableProps {
|
|
initialSearch?: string;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
/**
|
|
* Required, not optional: every mutation here has to end in a refresh of the
|
|
* caller's own data. The table used to fall back to `router.refresh()` when
|
|
* this was omitted, which re-rendered the whole route to redraw one table —
|
|
* and lost the editor state and scroll position that go with it.
|
|
*/
|
|
onRefresh: () => void;
|
|
items: CatalogItemData[];
|
|
pageId: number;
|
|
pageLayout: string;
|
|
canEdit: boolean;
|
|
interactionTypes: string[];
|
|
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
|
allPages: { id: number; caption: string }[];
|
|
/** Which catalog these offers belong to; bulk editing follows it. */
|
|
catalog?: "normal" | "bc";
|
|
}
|
|
|
|
export function CatalogItemsTable({
|
|
initialSearch = "",
|
|
onDirtyChange,
|
|
onRefresh,
|
|
items,
|
|
pageId,
|
|
pageLayout,
|
|
canEdit,
|
|
interactionTypes,
|
|
furniRevisionMap,
|
|
allPages,
|
|
catalog = "normal",
|
|
}: CatalogItemsTableProps) {
|
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
|
const discovery = useTranslations("pages.admin.catalog.discovery");
|
|
const t = useTranslations(
|
|
"pages.admin.catalog.catalogItemsTable.catalogItemsTable",
|
|
);
|
|
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
|
CatalogItemData[] | null
|
|
>(null);
|
|
const { run, isPending } = useServerAction();
|
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
|
/**
|
|
* A delete keeps every removed row, so it can be taken back. The affordance
|
|
* lives in the toast rather than in the toolbar because the window is short:
|
|
* the audit record outlives it, but a bulk delete is not something to restore
|
|
* from a dropdown ten minutes later.
|
|
*/
|
|
function offerUndoDelete(restoreId: number, count: number) {
|
|
if (!Number.isSafeInteger(restoreId) || restoreId < 1) return;
|
|
// The panel below holds the same records, so a restore missed here is not a
|
|
// lost delete.
|
|
toast.success(`Deleted ${count} item(s).`, {
|
|
action: {
|
|
label: "Undo",
|
|
onClick: () => {
|
|
void restoreDeletedCatalogItems({
|
|
restoreId,
|
|
requestKey: crypto.randomUUID(),
|
|
}).then((result) => {
|
|
onRefresh();
|
|
if (!result.ok)
|
|
toast.error(result.error ?? "Could not restore the items.");
|
|
else toast.success(`Restored ${result.data.restored} item(s).`);
|
|
});
|
|
},
|
|
},
|
|
});
|
|
}
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<CatalogItemData | null>(null);
|
|
const [editingBase, setEditingBase] = useState<BaseItemData | null>(null);
|
|
const [savingItemId, setSavingItemId] = useState<number | null>(null);
|
|
|
|
const [newItem, setNewItem] = useState({ ...DEFAULT_NEW_ITEM });
|
|
|
|
// ── View mode (table / grid) ────────────────────────────────────
|
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
|
|
|
// ── Search / Filter ─────────────────────────────────────────────
|
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
|
const [filters, setFilters] = useState<Record<string, string>>({
|
|
offerFilter: "all",
|
|
clubOnlyFilter: "all",
|
|
haveOfferFilter: "all",
|
|
});
|
|
|
|
const { items: filteredItems, counts: filterCounts } = useMemo(
|
|
() => discoverOffers(items, searchQuery, filters),
|
|
[items, searchQuery, filters],
|
|
);
|
|
const hasDiscoveryFilter =
|
|
Boolean(searchQuery.trim()) ||
|
|
Object.values(filters).some((f) => f !== "all");
|
|
|
|
// ── Move items dialog ───────────────────────────────────────────
|
|
const [moveOpen, setMoveOpen] = useState(false);
|
|
const [moveTargetPageId, setMoveTargetPageId] = useState<number | null>(null);
|
|
|
|
// ── Bulk price dialog ───────────────────────────────────────────
|
|
const [bulkPriceOpen, setBulkPriceOpen] = useState(false);
|
|
const [bulkPriceField, setBulkPriceField] = useState<
|
|
"costCredits" | "costPoints"
|
|
>("costCredits");
|
|
const [bulkPriceMode, setBulkPriceMode] = useState<"set" | "add" | "percent">(
|
|
"set",
|
|
);
|
|
const [bulkPriceValue, setBulkPriceValue] = useState(0);
|
|
|
|
// ── Drag & Drop (dnd-kit) ───────────────────────────────────────
|
|
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
|
const [itemOrder, setItemOrder] = useState<number[]>(items.map((i) => i.id));
|
|
const dndSensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
);
|
|
|
|
const orderedFilteredItems = useMemo(() => {
|
|
if (searchQuery.trim() || Object.values(filters).some((f) => f !== "all"))
|
|
return filteredItems;
|
|
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
|
|
return filteredItems
|
|
.slice()
|
|
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
|
}, [filteredItems, itemOrder, searchQuery, filters]);
|
|
|
|
// ── Selection ────────────────────────────────────────────────────
|
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
|
|
|
const toggleOne = useCallback((id: number) => {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const allVisibleSelected =
|
|
filteredItems.length > 0 &&
|
|
filteredItems.every((item) => selected.has(item.id));
|
|
const someVisibleSelected = filteredItems.some((item) =>
|
|
selected.has(item.id),
|
|
);
|
|
const toggleAll = useCallback(() => {
|
|
setSelected((previous) => {
|
|
const next = new Set(previous);
|
|
const allSelected = filteredItems.every((item) => previous.has(item.id));
|
|
for (const item of filteredItems) {
|
|
if (allSelected) next.delete(item.id);
|
|
else next.add(item.id);
|
|
}
|
|
return next;
|
|
});
|
|
}, [filteredItems]);
|
|
function updateSearch(query: string) {
|
|
setSearchQuery(query);
|
|
setSelected(new Set());
|
|
}
|
|
function updateFilter(key: string, value: string) {
|
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
setSelected(new Set());
|
|
}
|
|
function resetDiscovery() {
|
|
setSearchQuery("");
|
|
setFilters({
|
|
offerFilter: "all",
|
|
clubOnlyFilter: "all",
|
|
haveOfferFilter: "all",
|
|
});
|
|
setSelected(new Set());
|
|
}
|
|
|
|
// ── Inline edits ─────────────────────────────────────────────────
|
|
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
|
|
Object.fromEntries(
|
|
items.map((item) => [
|
|
item.id,
|
|
{
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
|
|
function updateInline(
|
|
id: number,
|
|
key: keyof InlineEdit,
|
|
value: string | number,
|
|
) {
|
|
setInlineEdits((prev) => ({
|
|
...prev,
|
|
[id]: { ...prev[id], [key]: value },
|
|
}));
|
|
}
|
|
|
|
const isItemModified = useCallback(
|
|
(item: CatalogItemData) => {
|
|
const edit = inlineEdits[item.id];
|
|
if (!edit) return false;
|
|
return (
|
|
edit.costCredits !== item.costCredits ||
|
|
edit.costPoints !== item.costPoints ||
|
|
edit.pointsType !== item.pointsType ||
|
|
edit.amount !== item.amount
|
|
);
|
|
},
|
|
[inlineEdits],
|
|
);
|
|
|
|
const modifiedItems = useMemo(
|
|
() => items.filter(isItemModified),
|
|
[items, isItemModified],
|
|
);
|
|
|
|
const hasOrderChanges = useMemo(() => {
|
|
const originalOrder = items.map((i) => i.id);
|
|
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
|
|
}, [items, itemOrder]);
|
|
|
|
const hasUnsavedChanges =
|
|
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
|
|
useEffect(() => {
|
|
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
|
|
setInlineEdits(
|
|
Object.fromEntries(
|
|
items.map((item) => [
|
|
item.id,
|
|
{
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
setItemOrder(items.map((item) => item.id));
|
|
setBulkRefreshFrom(null);
|
|
}, [items, bulkRefreshFrom]);
|
|
useEffect(() => {
|
|
onDirtyChange?.(hasUnsavedChanges);
|
|
}, [hasUnsavedChanges, onDirtyChange]);
|
|
|
|
// Warn before navigating away with unsaved changes
|
|
useEffect(() => {
|
|
if (!hasUnsavedChanges) return;
|
|
function onBeforeUnload(e: BeforeUnloadEvent) {
|
|
e.preventDefault();
|
|
}
|
|
window.addEventListener("beforeunload", onBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
|
}, [hasUnsavedChanges]);
|
|
|
|
// Ctrl+S / Cmd+S to save modified items
|
|
useEffect(() => {
|
|
if (!canEdit) return;
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (modifiedItems.length > 0) handleSaveAllModified();
|
|
else if (hasOrderChanges) handleSaveOrder();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
});
|
|
|
|
function resetInlineEdit(id: number) {
|
|
const item = items.find((i) => i.id === id);
|
|
if (!item) return;
|
|
setInlineEdits((prev) => ({
|
|
...prev,
|
|
[id]: {
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function resetAllEdits() {
|
|
setInlineEdits(
|
|
Object.fromEntries(
|
|
items.map((item) => [
|
|
item.id,
|
|
{
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Open edit dialog ───────────────────────────────────────────
|
|
function openEditDialog(item: CatalogItemData) {
|
|
setEditingItem({ ...item });
|
|
setEditingBase(item.baseItem ? { ...item.baseItem } : null);
|
|
}
|
|
|
|
// ── Save single item inline ──────────────────────────────────────
|
|
async function handleSaveOne(item: CatalogItemData) {
|
|
const edit = inlineEdits[item.id];
|
|
if (!edit) return;
|
|
setSavingItemId(item.id);
|
|
|
|
run(
|
|
() =>
|
|
updateCatalogItem({
|
|
id: item.id,
|
|
catalog,
|
|
catalogFields: {
|
|
catalogName: item.catalogName,
|
|
itemIds: item.itemIds,
|
|
costCredits: edit.costCredits,
|
|
costPoints: edit.costPoints,
|
|
pointsType: edit.pointsType,
|
|
amount: edit.amount,
|
|
limitedSells: item.limitedSells,
|
|
limitedStack: item.limitedStack,
|
|
orderNumber: item.orderNumber,
|
|
offerId: item.offerId,
|
|
haveOffer: item.haveOffer,
|
|
clubOnly: item.clubOnly,
|
|
extradata: item.extradata,
|
|
},
|
|
}),
|
|
{
|
|
successMessage: `Item #${item.id} saved.`,
|
|
errorMessage: `Failed to save item #${item.id}.`,
|
|
onSuccess: () => {
|
|
setSavingItemId(null);
|
|
onRefresh();
|
|
},
|
|
onError: () => setSavingItemId(null),
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Save all modified items ──────────────────────────────────────
|
|
async function handleSaveAllModified() {
|
|
if (modifiedItems.length === 0) return;
|
|
|
|
run(
|
|
async () => {
|
|
let savedCount = 0;
|
|
let failedCount = 0;
|
|
|
|
for (const item of modifiedItems) {
|
|
const edit = inlineEdits[item.id];
|
|
if (!edit) continue;
|
|
|
|
const result = await updateCatalogItem({
|
|
id: item.id,
|
|
catalog,
|
|
catalogFields: {
|
|
catalogName: item.catalogName,
|
|
itemIds: item.itemIds,
|
|
costCredits: edit.costCredits,
|
|
costPoints: edit.costPoints,
|
|
pointsType: edit.pointsType,
|
|
amount: edit.amount,
|
|
limitedSells: item.limitedSells,
|
|
limitedStack: item.limitedStack,
|
|
orderNumber: item.orderNumber,
|
|
offerId: item.offerId,
|
|
haveOffer: item.haveOffer,
|
|
clubOnly: item.clubOnly,
|
|
extradata: item.extradata,
|
|
},
|
|
});
|
|
|
|
if (result.ok) savedCount++;
|
|
else failedCount++;
|
|
}
|
|
|
|
if (savedCount > 0) {
|
|
onRefresh();
|
|
}
|
|
if (failedCount > 0) {
|
|
return {
|
|
ok: false as const,
|
|
error: `Saved ${savedCount}, failed ${failedCount}.`,
|
|
};
|
|
}
|
|
return { ok: true as const, data: { savedCount } };
|
|
},
|
|
{
|
|
successMessage: `Saved ${modifiedItems.length} item(s).`,
|
|
errorMessage: "Failed to save items.",
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Bulk delete selected ─────────────────────────────────────────
|
|
async function handleBulkDelete() {
|
|
if (selected.size === 0) return;
|
|
const ok = await confirm({
|
|
title: "Delete items",
|
|
description: `Delete ${selected.size} selected item(s)?`,
|
|
confirmLabel: "Delete",
|
|
});
|
|
if (!ok) return;
|
|
|
|
const ids = [...selected];
|
|
run(
|
|
() =>
|
|
deleteCatalogItems({ ids, requestKey: crypto.randomUUID(), catalog }),
|
|
{
|
|
successMessage: `Deleted ${ids.length} item(s).`,
|
|
errorMessage: "Failed to delete items.",
|
|
onSuccess: (data) => {
|
|
setSelected(new Set());
|
|
onRefresh();
|
|
offerUndoDelete(Number(data.restoreId), ids.length);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Move selected items to another page ─────────────────────────
|
|
async function handleMoveItems() {
|
|
if (selected.size === 0 || !moveTargetPageId) return;
|
|
|
|
// Record move patterns for all selected items
|
|
for (const id of selected) {
|
|
const item = items.find((i) => i.id === id);
|
|
if (item) {
|
|
recordMove({
|
|
interactionType: item.baseItem?.interactionType,
|
|
itemName: item.baseItemName,
|
|
targetPageId: moveTargetPageId,
|
|
});
|
|
}
|
|
}
|
|
|
|
run(
|
|
() =>
|
|
moveCatalogItems({
|
|
ids: [...selected],
|
|
targetPageId: moveTargetPageId,
|
|
catalog,
|
|
}),
|
|
{
|
|
successMessage: `Moved ${selected.size} item(s) to page #${moveTargetPageId}.`,
|
|
errorMessage: "Failed to move items.",
|
|
onSuccess: () => {
|
|
setSelected(new Set());
|
|
setMoveOpen(false);
|
|
setMoveTargetPageId(null);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Apply bulk price update to inline edits ────────────────────
|
|
function applyBulkPrice() {
|
|
const targetIds =
|
|
selected.size > 0 ? selected : new Set(filteredItems.map((i) => i.id));
|
|
setInlineEdits((prev) => {
|
|
const next = { ...prev };
|
|
for (const id of targetIds) {
|
|
const edit = next[id];
|
|
if (!edit) continue;
|
|
const currentVal = edit[bulkPriceField];
|
|
let newVal: number;
|
|
if (bulkPriceMode === "set") {
|
|
newVal = bulkPriceValue;
|
|
} else if (bulkPriceMode === "add") {
|
|
newVal = currentVal + bulkPriceValue;
|
|
} else {
|
|
newVal = Math.round(currentVal * (1 + bulkPriceValue / 100));
|
|
}
|
|
next[id] = { ...edit, [bulkPriceField]: Math.max(0, newVal) };
|
|
}
|
|
return next;
|
|
});
|
|
setBulkPriceOpen(false);
|
|
toast.success(
|
|
`Price update applied to ${targetIds.size} item(s). Save to persist.`,
|
|
);
|
|
}
|
|
|
|
// ── Drag & Drop handlers (dnd-kit) ──────────────────────────────
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
setActiveDragId(Number(event.active.id));
|
|
}, []);
|
|
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
setActiveDragId(null);
|
|
if (!over || active.id === over.id) return;
|
|
setItemOrder((prev) => {
|
|
const fromIdx = prev.indexOf(Number(active.id));
|
|
const toIdx = prev.indexOf(Number(over.id));
|
|
if (fromIdx === -1 || toIdx === -1) return prev;
|
|
return arrayMove(prev, fromIdx, toIdx);
|
|
});
|
|
}, []);
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
setActiveDragId(null);
|
|
}, []);
|
|
|
|
async function handleSaveOrder() {
|
|
const orders = itemOrder.map((id, idx) => ({ id, orderNumber: idx + 1 }));
|
|
|
|
run(() => reorderCatalogItems({ orders, catalog }), {
|
|
successMessage: "Item order saved.",
|
|
errorMessage: "Failed to save order.",
|
|
onSuccess: () => onRefresh(),
|
|
});
|
|
}
|
|
|
|
function resetOrder() {
|
|
setItemOrder(items.map((i) => i.id));
|
|
}
|
|
|
|
// Helper to get furni revision info for an item
|
|
function getFurniInfo(item: CatalogItemData) {
|
|
const firstBaseId = item.itemIds
|
|
.split(";")
|
|
.map(Number)
|
|
.find((n) => n > 0);
|
|
if (!firstBaseId) return { classname: item.baseItemName, revision: 0 };
|
|
const furni = furniRevisionMap[firstBaseId];
|
|
return furni
|
|
? {
|
|
classname: furni.classname || item.baseItemName,
|
|
revision: furni.revision,
|
|
}
|
|
: { classname: item.baseItemName, revision: 0 };
|
|
}
|
|
|
|
// ── Original CRUD handlers ───────────────────────────────────────
|
|
async function handleAddItem() {
|
|
run(
|
|
() =>
|
|
createCatalogItem(
|
|
{
|
|
...newItem,
|
|
pageId,
|
|
haveOffer: newItem.haveOffer as "0" | "1",
|
|
clubOnly: newItem.clubOnly as "0" | "1",
|
|
},
|
|
catalog,
|
|
),
|
|
{
|
|
successMessage: "Item added.",
|
|
errorMessage: "Failed to add item",
|
|
onSuccess: () => {
|
|
setAddOpen(false);
|
|
setNewItem({ ...DEFAULT_NEW_ITEM });
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async function handleDeleteItem(id: number) {
|
|
const ok = await confirm({
|
|
title: "Delete item",
|
|
description: "Delete this catalog item?",
|
|
confirmLabel: "Delete",
|
|
});
|
|
if (!ok) return;
|
|
run(
|
|
() =>
|
|
deleteCatalogItems({
|
|
ids: [id],
|
|
requestKey: crypto.randomUUID(),
|
|
catalog,
|
|
}),
|
|
{
|
|
successMessage: "Item deleted.",
|
|
errorMessage: "Failed to delete item.",
|
|
onSuccess: (data) => {
|
|
onRefresh();
|
|
offerUndoDelete(Number(data.restoreId), 1);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Move single item to another page ─────────────────────────
|
|
const [moveOneId, setMoveOneId] = useState<number | null>(null);
|
|
const [moveOneTarget, setMoveOneTarget] = useState<number | null>(null);
|
|
|
|
// Get the item being moved for suggestions
|
|
const moveOneItem = moveOneId ? items.find((i) => i.id === moveOneId) : null;
|
|
const moveOneSuggestions = useMemo(() => {
|
|
if (!moveOneItem) return [];
|
|
return getSuggestions({
|
|
interactionType: moveOneItem.baseItem?.interactionType,
|
|
itemName: moveOneItem.baseItemName,
|
|
});
|
|
}, [moveOneItem]);
|
|
|
|
async function handleMoveSingleItem() {
|
|
if (!moveOneId || !moveOneTarget) return;
|
|
// Record for learning before moving
|
|
if (moveOneItem) {
|
|
recordMove({
|
|
interactionType: moveOneItem.baseItem?.interactionType,
|
|
itemName: moveOneItem.baseItemName,
|
|
targetPageId: moveOneTarget,
|
|
});
|
|
}
|
|
run(
|
|
() =>
|
|
moveCatalogItems({
|
|
ids: [moveOneId],
|
|
targetPageId: moveOneTarget,
|
|
catalog,
|
|
}),
|
|
{
|
|
successMessage: `Item #${moveOneId} moved to page #${moveOneTarget}.`,
|
|
errorMessage: "Failed to move item.",
|
|
onSuccess: () => {
|
|
setMoveOneId(null);
|
|
setMoveOneTarget(null);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
async function handleDuplicateItem(item: CatalogItemData) {
|
|
run(
|
|
() =>
|
|
createCatalogItem(
|
|
{
|
|
pageId,
|
|
itemIds: item.itemIds,
|
|
catalogName: item.catalogName,
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
limitedSells: 0,
|
|
limitedStack: item.limitedStack,
|
|
orderNumber: item.orderNumber + 1,
|
|
offerId: item.offerId,
|
|
songId: item.songId,
|
|
haveOffer: item.haveOffer as "0" | "1",
|
|
clubOnly: item.clubOnly as "0" | "1",
|
|
extradata: item.extradata,
|
|
},
|
|
catalog,
|
|
),
|
|
{
|
|
successMessage: `Item #${item.id} duplicated.`,
|
|
errorMessage: "Failed to duplicate item.",
|
|
onSuccess: () => onRefresh(),
|
|
},
|
|
);
|
|
}
|
|
|
|
async function handleUpdateItem() {
|
|
if (!editingItem) return;
|
|
run(
|
|
() =>
|
|
updateCatalogItem({
|
|
id: editingItem.id,
|
|
catalog,
|
|
catalogFields: {
|
|
catalogName: editingItem.catalogName,
|
|
itemIds: editingItem.itemIds,
|
|
costCredits: editingItem.costCredits,
|
|
costPoints: editingItem.costPoints,
|
|
pointsType: editingItem.pointsType,
|
|
amount: editingItem.amount,
|
|
limitedSells: editingItem.limitedSells,
|
|
limitedStack: editingItem.limitedStack,
|
|
orderNumber: editingItem.orderNumber,
|
|
offerId: editingItem.offerId,
|
|
songId: editingItem.songId,
|
|
haveOffer: editingItem.haveOffer,
|
|
clubOnly: editingItem.clubOnly,
|
|
extradata: editingItem.extradata,
|
|
},
|
|
...(editingBase
|
|
? {
|
|
baseItem: {
|
|
id: editingBase.id,
|
|
fields: editingBase as unknown as Record<string, unknown>,
|
|
},
|
|
}
|
|
: {}),
|
|
}),
|
|
{
|
|
successMessage: `Item #${editingItem.id} updated.`,
|
|
errorMessage: "Failed to update item",
|
|
onSuccess: () => {
|
|
setEditingItem(null);
|
|
setEditingBase(null);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Add Item Form Fields (for "Add" dialog) ───────────────────
|
|
|
|
return (
|
|
<>
|
|
{confirmDialog}
|
|
{canEdit && (
|
|
<details className="mb-3">
|
|
<summary className="cursor-pointer text-sm text-[var(--admin-text-muted)]">
|
|
Recent deletions
|
|
</summary>
|
|
<div className="mt-2">
|
|
<RecentDeletionsPanel onRestored={onRefresh} />
|
|
</div>
|
|
</details>
|
|
)}
|
|
{bulkRefreshFrom !== null && (
|
|
<p
|
|
role="status"
|
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
|
>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{bulk("refreshing")}
|
|
</p>
|
|
)}
|
|
<Card
|
|
inert={bulkRefreshFrom !== null}
|
|
aria-busy={bulkRefreshFrom !== null}
|
|
>
|
|
<CardHeader className="space-y-3">
|
|
<div className="flex flex-row items-center justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<CardTitle>
|
|
{t("itemsCount", { count: items.length })}
|
|
{hasDiscoveryFilter &&
|
|
filteredItems.length !== items.length && (
|
|
<span className="text-muted-foreground font-normal text-sm ml-2">
|
|
{discovery("shown", { count: filteredItems.length })}
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
{selected.size > 0 && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{discovery("selected", { count: selected.size })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end items-center">
|
|
{/* View toggle */}
|
|
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewMode("table")}
|
|
className={cn(
|
|
"flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
viewMode === "table"
|
|
? "bg-background shadow-sm text-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
title="Table view"
|
|
>
|
|
<Rows3 className="h-3.5 w-3.5" />
|
|
Table
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewMode("grid")}
|
|
className={cn(
|
|
"flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
|
viewMode === "grid"
|
|
? "bg-background shadow-sm text-foreground"
|
|
: "text-muted-foreground hover:text-foreground",
|
|
)}
|
|
title="Shop preview"
|
|
>
|
|
<LayoutGrid className="h-3.5 w-3.5" />
|
|
Preview
|
|
</button>
|
|
</div>
|
|
{canEdit && (
|
|
<>
|
|
{selected.size > 0 && (
|
|
<>
|
|
<BulkOfferEditor
|
|
ids={[...selected]}
|
|
allPages={allPages}
|
|
catalog={catalog}
|
|
disabled={
|
|
hasUnsavedChanges ||
|
|
isPending ||
|
|
savingItemId !== null
|
|
}
|
|
onApplied={() => {
|
|
setBulkRefreshFrom(items);
|
|
setSelected(new Set());
|
|
onRefresh();
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setMoveOpen(true)}
|
|
disabled={isPending}
|
|
>
|
|
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
|
Move ({selected.size})
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={handleBulkDelete}
|
|
disabled={isPending}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete ({selected.size})
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setBulkPriceOpen(true)}
|
|
disabled={isPending}
|
|
>
|
|
<DollarSign className="mr-2 h-4 w-4" />
|
|
Bulk Price
|
|
</Button>
|
|
{hasOrderChanges && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={resetOrder}
|
|
disabled={isPending}
|
|
>
|
|
<RotateCcw className="mr-2 h-4 w-4" />
|
|
Reset Order
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveOrder}
|
|
disabled={isPending}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
Save Order
|
|
</Button>
|
|
</>
|
|
)}
|
|
{modifiedItems.length > 0 && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={resetAllEdits}
|
|
disabled={isPending}
|
|
>
|
|
<RotateCcw className="mr-2 h-4 w-4" />
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveAllModified}
|
|
disabled={isPending}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
Save All ({modifiedItems.length})
|
|
</Button>
|
|
</>
|
|
)}
|
|
<QuickAddFurni
|
|
pageId={pageId}
|
|
furniRevisionMap={furniRevisionMap}
|
|
/>
|
|
<BulkImportItems
|
|
pageId={pageId}
|
|
onImported={() => {
|
|
onRefresh();
|
|
void refreshCatalogTree();
|
|
}}
|
|
/>
|
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
|
<Button size="sm" onClick={() => setAddOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Item
|
|
</Button>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Add Catalog Item</DialogTitle>
|
|
</DialogHeader>
|
|
<AddItemFormFields
|
|
data={newItem}
|
|
onChange={(key, value) =>
|
|
setNewItem((p) => ({ ...p, [key]: value }))
|
|
}
|
|
/>
|
|
<Button
|
|
onClick={handleAddItem}
|
|
disabled={isPending}
|
|
className="w-full"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
)}
|
|
Add Item
|
|
</Button>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unsaved changes banner */}
|
|
{hasUnsavedChanges && (
|
|
<div className="flex items-center gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-3 py-2 text-sm text-[var(--admin-warning)]">
|
|
<span className="flex-1">
|
|
You have unsaved changes (
|
|
{modifiedItems.length > 0
|
|
? `${modifiedItems.length} item(s) modified`
|
|
: ""}
|
|
{modifiedItems.length > 0 && hasOrderChanges ? ", " : ""}
|
|
{hasOrderChanges ? "order changed" : ""}). Press{" "}
|
|
<kbd className="rounded border bg-background px-1 font-mono text-xs">
|
|
Ctrl+S
|
|
</kbd>{" "}
|
|
to save.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Offer discovery */}
|
|
{items.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="min-w-0 flex-1">
|
|
<SearchInput
|
|
placeholder={discovery("searchPlaceholder")}
|
|
value={searchQuery}
|
|
onChange={updateSearch}
|
|
onClear={() => updateSearch("")}
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={filters.offerFilter}
|
|
onValueChange={(v) => updateFilter("offerFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("filterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.offerFilter}`)} (
|
|
{filterCounts[filters.offerFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OFFER_FILTERS.slice(0, 5).map((filter) => (
|
|
<SelectItem key={filter} value={filter}>
|
|
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
|
)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.clubOnlyFilter}
|
|
onValueChange={(v) => updateFilter("clubOnlyFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("clubOnlyFilterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.clubOnlyFilter}`)} (
|
|
{filterCounts[filters.clubOnlyFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
{discovery("filters.all")}
|
|
</SelectItem>
|
|
<SelectItem value="club_only_yes">
|
|
{discovery("filters.club_only_yes")}
|
|
</SelectItem>
|
|
<SelectItem value="club_only_no">
|
|
{discovery("filters.club_only_no")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.haveOfferFilter}
|
|
onValueChange={(v) => updateFilter("haveOfferFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("haveOfferFilterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.haveOfferFilter}`)} (
|
|
{filterCounts[filters.haveOfferFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
{discovery("filters.all")}
|
|
</SelectItem>
|
|
<SelectItem value="have_offer_yes">
|
|
{discovery("filters.have_offer_yes")}
|
|
</SelectItem>
|
|
<SelectItem value="have_offer_no">
|
|
{discovery("filters.have_offer_no")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{hasDiscoveryFilter && (
|
|
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
|
{discovery("reset")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{discovery("results", {
|
|
count: filteredItems.length,
|
|
total: items.length,
|
|
})}
|
|
{canEdit && ` ${discovery("selectionHint")}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
{items.length === 0 ? (
|
|
<div className="text-center py-8 space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
No items in this page.
|
|
</p>
|
|
{canEdit && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAddOpen(true)}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add First Item
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : filteredItems.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
{discovery("noResults")}
|
|
</p>
|
|
) : viewMode === "grid" ? (
|
|
<ItemsShopPreview
|
|
items={orderedFilteredItems as CatalogItemData[]}
|
|
furniRevisionMap={furniRevisionMap}
|
|
layout={pageLayout}
|
|
canEdit={canEdit}
|
|
onItemClick={canEdit ? (item) => openEditDialog(item) : undefined}
|
|
/>
|
|
) : (
|
|
<DndContext
|
|
sensors={dndSensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<div className="rounded-md border overflow-auto max-h-[600px]">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10 bg-background">
|
|
<TableRow>
|
|
{canEdit && <TableHead className="w-[30px] px-1" />}
|
|
{canEdit && (
|
|
<TableHead className="w-[40px] px-2">
|
|
<Checkbox
|
|
checked={allVisibleSelected}
|
|
indeterminate={
|
|
someVisibleSelected && !allVisibleSelected
|
|
}
|
|
onCheckedChange={toggleAll}
|
|
aria-label={discovery("selectVisible")}
|
|
/>
|
|
</TableHead>
|
|
)}
|
|
<TableHead className="w-[50px] px-1" />
|
|
<TableHead className="w-[60px]">ID</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Item IDs</TableHead>
|
|
<TableHead>Item Name</TableHead>
|
|
<TableHead className="min-w-[180px]">Price</TableHead>
|
|
<TableHead className="w-[70px]">Amt</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
{canEdit && (
|
|
<TableHead className="w-[100px]">Actions</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<SortableContext
|
|
items={orderedFilteredItems.map((i) => i.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<TableBody>
|
|
{orderedFilteredItems.map((item) => {
|
|
const pts = getPointsLabel(item.pointsType);
|
|
const edit = inlineEdits[item.id];
|
|
const modified = isItemModified(item);
|
|
const isSaving = savingItemId === item.id;
|
|
const furniInfo = getFurniInfo(item);
|
|
const sortableDisabled = !canEdit || hasDiscoveryFilter;
|
|
return (
|
|
<SortableRow
|
|
key={item.id}
|
|
id={item.id}
|
|
className={cn(
|
|
modified &&
|
|
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
|
|
)}
|
|
>
|
|
{({ dragHandleProps }) => (
|
|
<>
|
|
{canEdit && (
|
|
<TableCell className="px-1">
|
|
{!sortableDisabled ? (
|
|
<div
|
|
{...dragHandleProps}
|
|
className="flex items-center justify-center text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing"
|
|
title="Drag to reorder"
|
|
>
|
|
<GripVertical className="h-4 w-4" />
|
|
</div>
|
|
) : (
|
|
<div className="h-4 w-4" />
|
|
)}
|
|
</TableCell>
|
|
)}
|
|
{canEdit && (
|
|
<TableCell className="px-2">
|
|
<Checkbox
|
|
checked={selected.has(item.id)}
|
|
onCheckedChange={() => toggleOne(item.id)}
|
|
aria-label={`Select item ${item.id}`}
|
|
/>
|
|
</TableCell>
|
|
)}
|
|
<TableCell className="px-1">
|
|
<FurniIcon
|
|
classname={furniInfo.classname}
|
|
revision={furniInfo.revision}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
{item.id}
|
|
</TableCell>
|
|
<TableCell className="font-medium text-sm max-w-[180px] truncate">
|
|
{item.catalogName || (
|
|
<span className="text-muted-foreground italic">
|
|
unnamed
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
{item.itemIds}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground font-mono max-w-[140px] truncate">
|
|
{item.baseItemName || "-"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{canEdit ? (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type="number"
|
|
value={
|
|
edit?.costCredits ?? item.costCredits
|
|
}
|
|
onChange={(e) =>
|
|
updateInline(
|
|
item.id,
|
|
"costCredits",
|
|
parseInt(e.target.value, 10) || 0,
|
|
)
|
|
}
|
|
className="h-8 text-xs w-16"
|
|
disabled={catalog === "bc"}
|
|
title={
|
|
catalog === "bc"
|
|
? "A Builder Club offer has no price of its own"
|
|
: "Credits"
|
|
}
|
|
/>
|
|
<span className="text-xs text-[var(--admin-warning)]">
|
|
c
|
|
</span>
|
|
<Input
|
|
type="number"
|
|
value={
|
|
edit?.costPoints ?? item.costPoints
|
|
}
|
|
onChange={(e) =>
|
|
updateInline(
|
|
item.id,
|
|
"costPoints",
|
|
parseInt(e.target.value, 10) || 0,
|
|
)
|
|
}
|
|
className="h-8 text-xs w-16"
|
|
disabled={catalog === "bc"}
|
|
title={
|
|
catalog === "bc"
|
|
? "A Builder Club offer has no price of its own"
|
|
: "Points"
|
|
}
|
|
/>
|
|
<span className={`text-xs ${pts.color}`}>
|
|
{pts.label.charAt(0)}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs flex flex-wrap items-center gap-1.5">
|
|
{item.costCredits > 0 && (
|
|
<span className="inline-flex items-center gap-0.5 text-[var(--admin-warning)]">
|
|
<CurrencyIcon
|
|
kind="credits"
|
|
size={12}
|
|
/>
|
|
{item.costCredits}
|
|
</span>
|
|
)}
|
|
{item.costPoints > 0 && (
|
|
<span
|
|
className={`inline-flex items-center gap-0.5 ${pts.color}`}
|
|
>
|
|
<CurrencyIcon
|
|
kind={currencyKindFromPointsType(
|
|
item.pointsType,
|
|
)}
|
|
size={12}
|
|
/>
|
|
{item.costPoints}
|
|
</span>
|
|
)}
|
|
{item.costCredits === 0 &&
|
|
item.costPoints === 0 && (
|
|
<span className="text-muted-foreground">
|
|
Free
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{canEdit ? (
|
|
<Input
|
|
type="number"
|
|
value={edit?.amount ?? item.amount}
|
|
onChange={(e) =>
|
|
updateInline(
|
|
item.id,
|
|
"amount",
|
|
parseInt(e.target.value, 10) || 1,
|
|
)
|
|
}
|
|
className="h-8 text-xs w-16"
|
|
/>
|
|
) : (
|
|
<span className="text-xs">
|
|
{item.amount}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Badge
|
|
variant={
|
|
item.haveOffer === "1"
|
|
? "default"
|
|
: "outline"
|
|
}
|
|
className="text-xs"
|
|
>
|
|
{item.haveOffer === "1"
|
|
? "Offer"
|
|
: "No Offer"}
|
|
</Badge>
|
|
{item.limitedStack > 0 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-xs"
|
|
>
|
|
LTD {item.limitedSells}/
|
|
{item.limitedStack}
|
|
</Badge>
|
|
)}
|
|
{item.clubOnly === "1" && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-xs"
|
|
>
|
|
HC
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
{canEdit && (
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
{modified && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-[var(--admin-success)]"
|
|
onClick={() => handleSaveOne(item)}
|
|
disabled={isSaving || isPending}
|
|
title="Save this item"
|
|
>
|
|
{isSaving ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Save className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() =>
|
|
resetInlineEdit(item.id)
|
|
}
|
|
title="Reset changes"
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => openEditDialog(item)}
|
|
title="Edit all fields"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() =>
|
|
handleDuplicateItem(item)
|
|
}
|
|
disabled={isPending}
|
|
title="Duplicate item"
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setMoveOneId(item.id)}
|
|
disabled={isPending}
|
|
title="Move to another page"
|
|
>
|
|
<MoveRight className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() =>
|
|
handleDeleteItem(item.id)
|
|
}
|
|
disabled={isPending}
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
</>
|
|
)}
|
|
</SortableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</SortableContext>
|
|
</Table>
|
|
</div>
|
|
<DragOverlay>
|
|
{activeDragId !== null &&
|
|
(() => {
|
|
const dragged = items.find((i) => i.id === activeDragId);
|
|
if (!dragged) return null;
|
|
const info = getFurniInfo(dragged);
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 shadow-lg">
|
|
<FurniIcon
|
|
classname={info.classname}
|
|
revision={info.revision}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">
|
|
{dragged.catalogName ||
|
|
dragged.baseName ||
|
|
`#${dragged.id}`}
|
|
</p>
|
|
<p className="font-mono text-[10px] text-muted-foreground">
|
|
#{dragged.id}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)}
|
|
|
|
{/* ── Edit Item Dialog (tabbed: Catalog Settings + Furniture Settings) ── */}
|
|
{editingItem && (
|
|
<Dialog
|
|
open={!!editingItem}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setEditingItem(null);
|
|
setEditingBase(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Item #{editingItem.id}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* Read-only identity fields */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">
|
|
Catalog Name
|
|
</Label>
|
|
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
|
{editingItem.catalogName || (
|
|
<span className="italic text-muted-foreground">
|
|
unnamed
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs text-muted-foreground">
|
|
Item IDs
|
|
</Label>
|
|
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm font-mono">
|
|
{editingItem.itemIds}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="catalog" className="w-full">
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="catalog" className="flex-1">
|
|
Catalog Settings
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="furniture"
|
|
className="flex-1"
|
|
disabled={!editingBase}
|
|
>
|
|
Furniture Settings
|
|
{!editingBase && (
|
|
<span className="ml-1 text-xs text-muted-foreground">
|
|
(N/A)
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* ── Catalog Settings Tab ── */}
|
|
<TabsContent value="catalog" className="space-y-3 mt-3">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Credits</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.costCredits}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
costCredits:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Points</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.costPoints}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
costPoints: parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Points Type</Label>
|
|
<Select
|
|
value={String(editingItem.pointsType)}
|
|
onValueChange={(v) =>
|
|
setEditingItem(
|
|
(p) => p && { ...p, pointsType: parseInt(v, 10) },
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{Object.entries(POINTS_TYPES).map(
|
|
([val, { label }]) => (
|
|
<SelectItem key={val} value={val}>
|
|
{label}
|
|
</SelectItem>
|
|
),
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Amount</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.amount}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
amount: parseInt(e.target.value, 10) || 1,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Order</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.orderNumber}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
orderNumber:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Offer ID</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.offerId}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
offerId: parseInt(e.target.value, 10),
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Limited Sells</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.limitedSells}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
limitedSells:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Limited Stack</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingItem.limitedStack}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
limitedStack:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Have Offer</Label>
|
|
<Select
|
|
value={editingItem.haveOffer}
|
|
onValueChange={(v) =>
|
|
setEditingItem((p) => p && { ...p, haveOffer: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1">Yes</SelectItem>
|
|
<SelectItem value="0">No</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Club Only</Label>
|
|
<Select
|
|
value={editingItem.clubOnly}
|
|
onValueChange={(v) =>
|
|
setEditingItem((p) => p && { ...p, clubOnly: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">No</SelectItem>
|
|
<SelectItem value="1">Yes</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Extra Data</Label>
|
|
<Input
|
|
value={editingItem.extradata}
|
|
onChange={(e) =>
|
|
setEditingItem(
|
|
(p) => p && { ...p, extradata: e.target.value },
|
|
)
|
|
}
|
|
placeholder="Optional extra data"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Song Disk</Label>
|
|
<SongPicker
|
|
value={editingItem.songId}
|
|
onChange={(id) =>
|
|
setEditingItem((p) => p && { ...p, songId: id })
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select a soundtrack to play when this item is used
|
|
in-game. Uploaded from <code>/admin/sounds</code>.
|
|
</p>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ── Furniture Settings Tab (items_base) ── */}
|
|
{editingBase && (
|
|
<TabsContent value="furniture" className="space-y-4 mt-3">
|
|
{/* Identity */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Identity
|
|
</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Base ID</Label>
|
|
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm font-mono">
|
|
{editingBase.id}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Sprite ID</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.spriteId}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
spriteId:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Type</Label>
|
|
<Select
|
|
value={editingBase.type}
|
|
onValueChange={(v) =>
|
|
setEditingBase((p) => p && { ...p, type: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="s">s (Floor)</SelectItem>
|
|
<SelectItem value="i">i (Wall)</SelectItem>
|
|
<SelectItem value="e">e (Effect)</SelectItem>
|
|
<SelectItem value="b">b (Badge)</SelectItem>
|
|
<SelectItem value="r">r (Robot)</SelectItem>
|
|
<SelectItem value="h">
|
|
h (Habbo Club)
|
|
</SelectItem>
|
|
<SelectItem value="p">p (Pet)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dimensions */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Dimensions
|
|
</h4>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Width</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.width}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
width: parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Length</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.length}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
length: parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Stack Height</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={editingBase.stackHeight}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
stackHeight:
|
|
parseFloat(e.target.value) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Interaction */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Interaction
|
|
</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Interaction Type</Label>
|
|
<InteractionTypeCombobox
|
|
value={editingBase.interactionType}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, interactionType: v },
|
|
)
|
|
}
|
|
types={interactionTypes}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Interaction Modes</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.interactionModesCount}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
interactionModesCount:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Permissions */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Permissions
|
|
</h4>
|
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border p-3">
|
|
<ToggleField
|
|
label="Allow Stack"
|
|
value={editingBase.allowStack}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowStack: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Sit"
|
|
value={editingBase.allowSit}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowSit: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Lay"
|
|
value={editingBase.allowLay}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowLay: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Walk"
|
|
value={editingBase.allowWalk}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowWalk: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Gift"
|
|
value={editingBase.allowGift}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowGift: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Trade"
|
|
value={editingBase.allowTrade}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowTrade: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Recycle"
|
|
value={editingBase.allowRecycle}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) => p && { ...p, allowRecycle: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Marketplace Sell"
|
|
value={editingBase.allowMarketplaceSell}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
allowMarketplaceSell: v ? 1 : 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
<ToggleField
|
|
label="Allow Inventory Stack"
|
|
value={editingBase.allowInventoryStack}
|
|
onChange={(v) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && { ...p, allowInventoryStack: v ? 1 : 0 },
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Extra */}
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Extra
|
|
</h4>
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Vending IDs</Label>
|
|
<Input
|
|
value={editingBase.vendingIds}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && { ...p, vendingIds: e.target.value },
|
|
)
|
|
}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Custom Params</Label>
|
|
<Input
|
|
value={editingBase.customparams}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
customparams: e.target.value,
|
|
},
|
|
)
|
|
}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Effect ID Male</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.effectIdMale}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
effectIdMale:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">
|
|
Effect ID Female
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
value={editingBase.effectIdFemale}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
effectIdFemale:
|
|
parseInt(e.target.value, 10) || 0,
|
|
},
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Clothing On Walk</Label>
|
|
<Input
|
|
value={editingBase.clothingOnWalk}
|
|
onChange={(e) =>
|
|
setEditingBase(
|
|
(p) =>
|
|
p && {
|
|
...p,
|
|
clothingOnWalk: e.target.value,
|
|
},
|
|
)
|
|
}
|
|
placeholder=""
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 mt-2">
|
|
<Button
|
|
onClick={handleUpdateItem}
|
|
disabled={isPending}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Save className="mr-2 h-4 w-4" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditingItem(null);
|
|
setEditingBase(null);
|
|
}}
|
|
>
|
|
<X className="mr-2 h-4 w-4" />
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
{/* ── Move Items Dialog ── */}
|
|
<Dialog
|
|
open={moveOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setMoveOpen(false);
|
|
setMoveTargetPageId(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
Move {selected.size} item(s) to another page
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<Label>Target Page</Label>
|
|
<MovePageCombobox
|
|
value={moveTargetPageId}
|
|
onChange={setMoveTargetPageId}
|
|
pages={allPages}
|
|
currentPageId={pageId}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleMoveItems}
|
|
disabled={isPending || !moveTargetPageId}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<ArrowRightLeft className="mr-2 h-4 w-4" />
|
|
)}
|
|
Move Items
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setMoveOpen(false);
|
|
setMoveTargetPageId(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Move Single Item Dialog ── */}
|
|
<Dialog
|
|
open={moveOneId !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setMoveOneId(null);
|
|
setMoveOneTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Move Item to another page</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
{/* Item info */}
|
|
{moveOneItem && (
|
|
<div className="flex items-center gap-3 rounded-md border bg-muted/30 px-3 py-2">
|
|
<FurniIcon
|
|
classname={getFurniInfo(moveOneItem).classname}
|
|
revision={getFurniInfo(moveOneItem).revision}
|
|
/>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{moveOneItem.catalogName || moveOneItem.baseName}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground font-mono">
|
|
#{moveOneItem.id} · {moveOneItem.baseItemName || "—"}
|
|
{moveOneItem.baseItem?.interactionType &&
|
|
` · ${moveOneItem.baseItem.interactionType}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Label>Target Page</Label>
|
|
<MovePageCombobox
|
|
value={moveOneTarget}
|
|
onChange={setMoveOneTarget}
|
|
pages={allPages}
|
|
currentPageId={pageId}
|
|
suggestedPageIds={moveOneSuggestions}
|
|
/>
|
|
{moveOneSuggestions.length > 0 && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Suggestions based on previous moves for similar items.
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={handleMoveSingleItem}
|
|
disabled={isPending || !moveOneTarget}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<MoveRight className="mr-2 h-4 w-4" />
|
|
)}
|
|
Move Item
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setMoveOneId(null);
|
|
setMoveOneTarget(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── Bulk Price Dialog ── */}
|
|
<Dialog open={bulkPriceOpen} onOpenChange={setBulkPriceOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Bulk Price Update</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground">
|
|
{selected.size > 0
|
|
? `Apply to ${selected.size} selected item(s).`
|
|
: `Apply to all ${filteredItems.length} visible item(s).`}
|
|
</p>
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label>Field</Label>
|
|
<Select
|
|
value={bulkPriceField}
|
|
onValueChange={(v) =>
|
|
setBulkPriceField(v as "costCredits" | "costPoints")
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="costCredits">Credits</SelectItem>
|
|
<SelectItem value="costPoints">Points</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Operation</Label>
|
|
<Select
|
|
value={bulkPriceMode}
|
|
onValueChange={(v) =>
|
|
setBulkPriceMode(v as "set" | "add" | "percent")
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="set">Set to value</SelectItem>
|
|
<SelectItem value="add">Add value</SelectItem>
|
|
<SelectItem value="percent">
|
|
Change by percentage
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>
|
|
{bulkPriceMode === "percent" ? "Percentage (%)" : "Value"}
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
value={bulkPriceValue}
|
|
onChange={(e) =>
|
|
setBulkPriceValue(parseFloat(e.target.value) || 0)
|
|
}
|
|
placeholder={
|
|
bulkPriceMode === "percent"
|
|
? "e.g. 10 for +10%"
|
|
: "e.g. 5"
|
|
}
|
|
/>
|
|
{bulkPriceMode === "percent" && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Use negative values to decrease (e.g. -20 for -20%)
|
|
</p>
|
|
)}
|
|
{bulkPriceMode === "add" && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Use negative values to subtract
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={applyBulkPrice} className="flex-1">
|
|
<DollarSign className="mr-2 h-4 w-4" />
|
|
Apply
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setBulkPriceOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
);
|
|
}
|