Some checks failed
Deploy to Production / test (push) Failing after 30s
Implements comprehensive user management interface for admin panel: - Search and filter users by username, class, and active status - Sort by username, level, balance, or XP with pagination - View and edit user details (balance, XP, level, class, daily streak, active status) - Manage user inventories (add/remove items with quantities) - Debounced search input (300ms delay) - Responsive design (mobile full-screen, desktop slide-in panel) - Draft state management with unsaved changes tracking - Keyboard shortcuts (Escape to close detail panel) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
344 lines
8.3 KiB
TypeScript
344 lines
8.3 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { get, put, post, del } from "./api";
|
|
|
|
export interface User {
|
|
id: string;
|
|
username: string;
|
|
classId: string | null;
|
|
isActive: boolean;
|
|
balance: string;
|
|
xp: string;
|
|
level: number;
|
|
dailyStreak: number;
|
|
settings: Record<string, unknown>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
class?: Class;
|
|
}
|
|
|
|
export interface Class {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
}
|
|
|
|
export interface Item {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
rarity: string;
|
|
sellPrice: string;
|
|
buyPrice: string;
|
|
}
|
|
|
|
export interface InventoryEntry {
|
|
userId: string;
|
|
itemId: number;
|
|
quantity: string;
|
|
item?: Item;
|
|
}
|
|
|
|
export interface UserFilters {
|
|
search: string;
|
|
classId: string | null;
|
|
isActive: boolean | null;
|
|
sortBy: "username" | "level" | "balance" | "xp";
|
|
sortOrder: "asc" | "desc";
|
|
}
|
|
|
|
export function useUsers() {
|
|
// User list state
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [limit, setLimit] = useState(50);
|
|
|
|
// Filters
|
|
const [filters, setFiltersState] = useState<UserFilters>({
|
|
search: "",
|
|
classId: null,
|
|
isActive: null,
|
|
sortBy: "balance",
|
|
sortOrder: "desc",
|
|
});
|
|
|
|
// Detail panel state
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
const [userDraft, setUserDraft] = useState<Partial<User> | null>(null);
|
|
const [inventoryDraft, setInventoryDraft] = useState<InventoryEntry[]>([]);
|
|
|
|
// Reference data
|
|
const [classes, setClasses] = useState<Class[]>([]);
|
|
const [items, setItems] = useState<Item[]>([]);
|
|
|
|
// UI state
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Fetch users with filters and pagination
|
|
const fetchUsers = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const params = new URLSearchParams();
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.classId) params.set("classId", filters.classId);
|
|
if (filters.isActive !== null) params.set("isActive", String(filters.isActive));
|
|
params.set("sortBy", filters.sortBy);
|
|
params.set("sortOrder", filters.sortOrder);
|
|
params.set("limit", String(limit));
|
|
params.set("offset", String((currentPage - 1) * limit));
|
|
|
|
const data = await get<{ users: User[]; total: number }>(
|
|
`/api/users?${params.toString()}`
|
|
);
|
|
|
|
setUsers(data.users);
|
|
setTotal(data.total);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to load users");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filters, currentPage, limit]);
|
|
|
|
// Fetch single user by ID
|
|
const fetchUserById = useCallback(async (id: string) => {
|
|
try {
|
|
const user = await get<User>(`/api/users/${id}`);
|
|
return user;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to load user");
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
// Fetch classes for filter dropdown
|
|
const fetchClasses = useCallback(async () => {
|
|
try {
|
|
const data = await get<{ classes: Class[] }>("/api/classes");
|
|
setClasses(data.classes || []);
|
|
} catch (e) {
|
|
console.error("Failed to load classes:", e);
|
|
setClasses([]);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch items for inventory management
|
|
const fetchItems = useCallback(async () => {
|
|
try {
|
|
const data = await get<{ items: Item[]; total: number }>("/api/items");
|
|
setItems(data.items || []);
|
|
} catch (e) {
|
|
console.error("Failed to load items:", e);
|
|
setItems([]);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch user inventory
|
|
const fetchInventory = useCallback(async (userId: string) => {
|
|
try {
|
|
const data = await get<{ inventory: InventoryEntry[] }>(
|
|
`/api/users/${userId}/inventory`
|
|
);
|
|
setInventoryDraft(data.inventory);
|
|
return data.inventory;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to load inventory");
|
|
return [];
|
|
}
|
|
}, []);
|
|
|
|
// Update user
|
|
const updateUser = useCallback(async (id: string, data: Partial<User>) => {
|
|
try {
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
const result = await put<{ success: boolean; user: User }>(
|
|
`/api/users/${id}`,
|
|
data
|
|
);
|
|
|
|
return result.user;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to update user");
|
|
return null;
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, []);
|
|
|
|
// Add item to inventory
|
|
const addInventoryItem = useCallback(
|
|
async (userId: string, itemId: number, quantity: string) => {
|
|
try {
|
|
await post(`/api/users/${userId}/inventory`, { itemId, quantity });
|
|
await fetchInventory(userId);
|
|
return true;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to add item");
|
|
return false;
|
|
}
|
|
},
|
|
[fetchInventory]
|
|
);
|
|
|
|
// Remove item from inventory
|
|
const removeInventoryItem = useCallback(
|
|
async (userId: string, itemId: number) => {
|
|
try {
|
|
await del(`/api/users/${userId}/inventory/${itemId}`);
|
|
await fetchInventory(userId);
|
|
return true;
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Failed to remove item");
|
|
return false;
|
|
}
|
|
},
|
|
[fetchInventory]
|
|
);
|
|
|
|
// Set filters and reset to page 1
|
|
const setFilters = useCallback((newFilters: Partial<UserFilters>) => {
|
|
setFiltersState((prev) => ({ ...prev, ...newFilters }));
|
|
setCurrentPage(1);
|
|
}, []);
|
|
|
|
// Debounced search setter
|
|
const setSearchDebounced = useCallback(
|
|
(() => {
|
|
let timeoutId: NodeJS.Timeout;
|
|
return (search: string) => {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => {
|
|
setFilters({ search });
|
|
}, 300);
|
|
};
|
|
})(),
|
|
[setFilters]
|
|
);
|
|
|
|
// Navigate to page
|
|
const setPage = useCallback((page: number) => {
|
|
setCurrentPage(page);
|
|
}, []);
|
|
|
|
// Select user and open detail panel
|
|
const selectUser = useCallback(
|
|
async (user: User) => {
|
|
setSelectedUser(user);
|
|
|
|
// Fetch fresh data
|
|
const freshUser = await fetchUserById(user.id);
|
|
if (freshUser) {
|
|
setSelectedUser(freshUser);
|
|
setUserDraft(structuredClone(freshUser));
|
|
await fetchInventory(freshUser.id);
|
|
}
|
|
},
|
|
[fetchUserById, fetchInventory]
|
|
);
|
|
|
|
// Close detail panel
|
|
const closeDetail = useCallback(() => {
|
|
setSelectedUser(null);
|
|
setUserDraft(null);
|
|
setInventoryDraft([]);
|
|
}, []);
|
|
|
|
// Update draft field
|
|
const updateDraft = useCallback((field: keyof User, value: unknown) => {
|
|
setUserDraft((prev) => {
|
|
if (!prev) return null;
|
|
return { ...prev, [field]: value };
|
|
});
|
|
}, []);
|
|
|
|
// Save draft changes
|
|
const saveDraft = useCallback(async () => {
|
|
if (!selectedUser || !userDraft) return false;
|
|
|
|
const updated = await updateUser(selectedUser.id, userDraft);
|
|
if (updated) {
|
|
setSelectedUser(updated);
|
|
setUserDraft(structuredClone(updated));
|
|
|
|
// Refresh the list to show updated data
|
|
await fetchUsers();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}, [selectedUser, userDraft, updateUser, fetchUsers]);
|
|
|
|
// Discard draft changes
|
|
const discardDraft = useCallback(() => {
|
|
if (selectedUser) {
|
|
setUserDraft(structuredClone(selectedUser));
|
|
}
|
|
}, [selectedUser]);
|
|
|
|
// Check if draft has changes
|
|
const isDirty = useCallback(() => {
|
|
if (!selectedUser || !userDraft) return false;
|
|
return JSON.stringify(selectedUser) !== JSON.stringify(userDraft);
|
|
}, [selectedUser, userDraft]);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
fetchClasses();
|
|
fetchItems();
|
|
}, [fetchUsers, fetchClasses, fetchItems]);
|
|
|
|
return {
|
|
// User list
|
|
users,
|
|
total,
|
|
currentPage,
|
|
limit,
|
|
setLimit,
|
|
|
|
// Filters
|
|
filters,
|
|
setFilters,
|
|
setSearchDebounced,
|
|
|
|
// Pagination
|
|
setPage,
|
|
|
|
// Detail panel
|
|
selectedUser,
|
|
selectUser,
|
|
closeDetail,
|
|
|
|
// Editing
|
|
userDraft,
|
|
updateDraft,
|
|
saveDraft,
|
|
discardDraft,
|
|
isDirty: isDirty(),
|
|
|
|
// Inventory
|
|
inventoryDraft,
|
|
addInventoryItem,
|
|
removeInventoryItem,
|
|
|
|
// Reference data
|
|
classes,
|
|
items,
|
|
|
|
// UI state
|
|
loading,
|
|
saving,
|
|
error,
|
|
|
|
// Actions
|
|
refetch: fetchUsers,
|
|
};
|
|
}
|