feat: add users management page with search, editing, and inventory control
Some checks failed
Deploy to Production / test (push) Failing after 30s
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>
This commit is contained in:
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
|
||||
import Layout, { type Page } from "./components/Layout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Settings from "./pages/Settings";
|
||||
import Users from "./pages/Users";
|
||||
import PlaceholderPage from "./pages/PlaceholderPage";
|
||||
|
||||
const placeholders: Record<string, { title: string; description: string }> = {
|
||||
@@ -75,6 +76,8 @@ export default function App() {
|
||||
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
|
||||
{page === "dashboard" ? (
|
||||
<Dashboard />
|
||||
) : page === "users" ? (
|
||||
<Users />
|
||||
) : page === "settings" ? (
|
||||
<Settings />
|
||||
) : (
|
||||
|
||||
343
panel/src/lib/useUsers.ts
Normal file
343
panel/src/lib/useUsers.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
1062
panel/src/pages/Users.tsx
Normal file
1062
panel/src/pages/Users.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user