diff --git a/src/__tests__/EmployeeTable.test.tsx b/src/__tests__/EmployeeTable.test.tsx
new file mode 100644
index 00000000..ff65b744
--- /dev/null
+++ b/src/__tests__/EmployeeTable.test.tsx
@@ -0,0 +1,180 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { ReactNode } from "react";
+
+import EmployeeTable from "../pages/EmployeeTable";
+import * as employeeApi from "../services/employeeApi";
+import type { Employee } from "../types/employee";
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+// Isolate from the real dnd-kit browser APIs
+vi.mock("@dnd-kit/core", () => ({
+ DndContext: ({ children }: { children: ReactNode }) => <>{children}>,
+ closestCenter: vi.fn(),
+ KeyboardSensor: class {},
+ PointerSensor: class {},
+ useSensor: vi.fn(),
+ useSensors: vi.fn(() => []),
+}));
+
+vi.mock("@dnd-kit/sortable", () => ({
+ SortableContext: ({ children }: { children: ReactNode }) => <>{children}>,
+ sortableKeyboardCoordinates: vi.fn(),
+ verticalListSortingStrategy: vi.fn(),
+ arrayMove: vi.fn((arr: unknown[]) => arr),
+}));
+
+// Thin stand-in so we can assert row rendering without mounting useSortable
+vi.mock("../components/SortableRow", () => ({
+ SortableRow: ({ employee }: { employee: Employee }) => (
+
+ | {employee.name} |
+ {employee.role} |
+
+ ),
+}));
+
+vi.mock("../services/employeeApi", () => ({
+ fetchEmployees: vi.fn(),
+ updateEmployeeOrder: vi.fn(),
+}));
+
+// Allow per-test control of mutationError
+const mockReorder = vi.fn();
+let mockMutationError: Error | null = null;
+
+vi.mock("../hooks/useUpdateEmployeeOrder", () => ({
+ useUpdateEmployeeOrder: () => ({
+ mutate: mockReorder,
+ error: mockMutationError,
+ }),
+ EMPLOYEES_QUERY_KEY: ["employees"],
+}));
+
+// ── Fixtures ───────────────────────────────────────────────────────────────────
+
+const EMPLOYEES: Employee[] = [
+ {
+ id: "1",
+ name: "Alice",
+ role: "Engineer",
+ walletAddress: "ADDR1",
+ currency: "USDC",
+ salary: 5000,
+ orderIndex: 0,
+ },
+ {
+ id: "2",
+ name: "Bob",
+ role: "Designer",
+ walletAddress: "ADDR2",
+ currency: "USDC",
+ salary: 4500,
+ orderIndex: 1,
+ },
+];
+
+function createClient() {
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+}
+
+function renderTable(queryClient = createClient()) {
+ return render(
+
+
+ ,
+ );
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe("EmployeeTable", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockMutationError = null;
+ });
+
+ it("shows a loading indicator while data is in-flight", () => {
+ vi.mocked(employeeApi.fetchEmployees).mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ renderTable();
+
+ expect(screen.getByText(/loading employees/i)).toBeInTheDocument();
+ });
+
+ it("shows an error message when fetchEmployees rejects", async () => {
+ vi.mocked(employeeApi.fetchEmployees).mockRejectedValue(new Error("500"));
+
+ renderTable();
+
+ await waitFor(() => {
+ expect(screen.getByText(/failed to load employees/i)).toBeInTheDocument();
+ });
+ });
+
+ it("renders the table headings and a row for each employee", async () => {
+ vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
+
+ renderTable();
+
+ await waitFor(() => {
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Role")).toBeInTheDocument();
+ expect(screen.getByText("Wallet")).toBeInTheDocument();
+ expect(screen.getByText("Currency")).toBeInTheDocument();
+ expect(screen.getByText("Salary")).toBeInTheDocument();
+
+ const rows = screen.getAllByTestId("employee-row");
+ expect(rows).toHaveLength(EMPLOYEES.length);
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Bob")).toBeInTheDocument();
+ });
+
+ it("shows a mutation error alert when the reorder API call fails", async () => {
+ vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
+ mockMutationError = new Error("Reorder failed");
+
+ renderTable();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole("alert")).toHaveTextContent(/reorder failed/i);
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ /previous order has been restored/i,
+ );
+ });
+
+ it("does not show the alert banner when there is no mutation error", async () => {
+ vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
+ mockMutationError = null;
+
+ renderTable();
+
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+ });
+
+ it("renders a table element with thead and tbody", async () => {
+ vi.mocked(employeeApi.fetchEmployees).mockResolvedValue(EMPLOYEES);
+
+ const { container } = renderTable();
+
+ await waitFor(() => {
+ expect(container.querySelector("table")).toBeInTheDocument();
+ });
+
+ expect(container.querySelector("thead")).toBeInTheDocument();
+ expect(container.querySelector("tbody")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/SortableRow.test.tsx b/src/__tests__/SortableRow.test.tsx
new file mode 100644
index 00000000..720c8498
--- /dev/null
+++ b/src/__tests__/SortableRow.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { SortableRow } from "../components/SortableRow";
+import type { Employee } from "../types/employee";
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock("@dnd-kit/sortable", () => ({
+ useSortable: vi.fn(() => ({
+ attributes: { role: "button" },
+ listeners: { onKeyDown: vi.fn() },
+ setNodeRef: vi.fn(),
+ setActivatorNodeRef: vi.fn(),
+ transform: null,
+ transition: undefined,
+ isDragging: false,
+ })),
+}));
+
+vi.mock("@dnd-kit/utilities", () => ({
+ CSS: { Transform: { toString: () => "" } },
+}));
+
+// CSS modules resolve to plain objects in jsdom — no extra mock needed.
+
+// Fixtures
+
+const EMPLOYEE: Employee = {
+ id: "emp-1",
+ name: "Alice Nguyen",
+ role: "Engineer",
+ walletAddress: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGPWQTESTADDR",
+ currency: "USDC",
+ salary: 5000,
+ orderIndex: 0,
+};
+
+function renderRow(employee: Employee = EMPLOYEE) {
+ // SortableRow renders a which requires a table context to be valid HTML.
+ return render(
+ ,
+ );
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe("SortableRow", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the employee name, role, currency and salary", () => {
+ renderRow();
+
+ expect(screen.getByText("Alice Nguyen")).toBeInTheDocument();
+ expect(screen.getByText("Engineer")).toBeInTheDocument();
+ expect(screen.getByText("USDC")).toBeInTheDocument();
+ expect(screen.getByText("5,000")).toBeInTheDocument();
+ });
+
+ it("truncates the wallet address and preserves full address in title", () => {
+ renderRow();
+
+ const walletSpan = screen.getByTitle(EMPLOYEE.walletAddress);
+ expect(walletSpan).toBeInTheDocument();
+
+ // First 6 chars + ellipsis + last 4 chars
+ const expected = `${EMPLOYEE.walletAddress.slice(0, 6)}…${EMPLOYEE.walletAddress.slice(-4)}`;
+ expect(walletSpan.textContent).toBe(expected);
+ });
+
+ it("renders a drag handle button with the correct aria-label", () => {
+ renderRow();
+
+ const handle = screen.getByRole("button", { name: "Reorder employee" });
+ expect(handle).toBeInTheDocument();
+ });
+
+ it("drag handle has tabIndex={0} for keyboard accessibility", () => {
+ renderRow();
+
+ const handle = screen.getByRole("button", { name: "Reorder employee" });
+ expect(handle).toHaveAttribute("tabindex", "0");
+ });
+
+ it("grip SVG icon has aria-hidden so screen readers skip it", () => {
+ renderRow();
+
+ const svg = screen
+ .getByRole("button", { name: "Reorder employee" })
+ .querySelector("svg");
+
+ expect(svg).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("applies the dragging class when isDragging is true", async () => {
+ const { useSortable } = await import("@dnd-kit/sortable");
+ vi.mocked(useSortable).mockReturnValueOnce({
+ attributes: {},
+ listeners: {},
+ setNodeRef: vi.fn(),
+ setActivatorNodeRef: vi.fn(),
+ transform: null,
+ transition: undefined,
+ isDragging: true,
+ // cast because real return type has many more fields we don't need
+ } as unknown as ReturnType);
+
+ const { container } = renderRow();
+ const row = container.querySelector("tr");
+
+ // The CSS module class name in jsdom tests resolves to the key string.
+ expect(row?.className).toContain("dragging");
+ });
+
+ it("does not apply a className when isDragging is false", () => {
+ const { container } = renderRow();
+ const row = container.querySelector("tr");
+ expect(row?.className).toBeFalsy();
+ });
+});
diff --git a/src/__tests__/useUpdateEmployeeOrder.test.tsx b/src/__tests__/useUpdateEmployeeOrder.test.tsx
new file mode 100644
index 00000000..28edf05d
--- /dev/null
+++ b/src/__tests__/useUpdateEmployeeOrder.test.tsx
@@ -0,0 +1,201 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { ReactNode } from "react";
+
+import {
+ useUpdateEmployeeOrder,
+ EMPLOYEES_QUERY_KEY,
+} from "../hooks/useUpdateEmployeeOrder";
+import * as employeeApi from "../services/employeeApi";
+import type { Employee } from "../types/employee";
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock("../services/employeeApi", () => ({
+ updateEmployeeOrder: vi.fn(),
+ fetchEmployees: vi.fn(),
+}));
+
+// ── Fixtures ───────────────────────────────────────────────────────────────────
+
+const EMPLOYEES: Employee[] = [
+ {
+ id: "1",
+ name: "Alice",
+ role: "Eng",
+ walletAddress: "ADDR1111",
+ currency: "USDC",
+ salary: 5000,
+ orderIndex: 0,
+ },
+ {
+ id: "2",
+ name: "Bob",
+ role: "Des",
+ walletAddress: "ADDR2222",
+ currency: "USDC",
+ salary: 4500,
+ orderIndex: 1,
+ },
+ {
+ id: "3",
+ name: "Carol",
+ role: "PM",
+ walletAddress: "ADDR3333",
+ currency: "XLM",
+ salary: 6000,
+ orderIndex: 2,
+ },
+];
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
+ });
+ queryClient.setQueryData(EMPLOYEES_QUERY_KEY, [...EMPLOYEES]);
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ );
+ }
+
+ return { queryClient, Wrapper };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe("useUpdateEmployeeOrder", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("optimistically reorders the cache in onMutate before the API resolves", async () => {
+ // Never resolves — lets us inspect cache while mutationFn is in-flight
+ vi.mocked(employeeApi.updateEmployeeOrder).mockReturnValue(
+ new Promise(() => {}),
+ );
+
+ const { queryClient, Wrapper } = createWrapper();
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "3" });
+ });
+
+ // onMutate is async but settles on the microtask queue
+ await waitFor(() => {
+ const cached = queryClient.getQueryData(EMPLOYEES_QUERY_KEY)!;
+ // Alice (id=1) should now be at index 2, Carol (id=3) at index 0
+ expect(cached[0].id).toBe("2");
+ expect(cached[1].id).toBe("3");
+ expect(cached[2].id).toBe("1");
+ });
+ });
+
+ it("sends only minimal { id, newIndex } payload — not the full Employee object", async () => {
+ vi.mocked(employeeApi.updateEmployeeOrder).mockResolvedValue(undefined);
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "2" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ const callArg = vi.mocked(employeeApi.updateEmployeeOrder).mock.calls[0][0];
+
+ // Every item must only carry id and newIndex
+ callArg.forEach((item, index) => {
+ expect(Object.keys(item)).toEqual(["id", "newIndex"]);
+ expect(item.newIndex).toBe(index);
+ });
+ });
+
+ it("rollbacks to the previous cache state when the API returns an error", async () => {
+ vi.mocked(employeeApi.updateEmployeeOrder).mockRejectedValue(
+ new Error("Network failure"),
+ );
+
+ const { queryClient, Wrapper } = createWrapper();
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "3" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ const cached = queryClient.getQueryData(EMPLOYEES_QUERY_KEY)!;
+ expect(cached.map((e) => e.id)).toEqual(["1", "2", "3"]);
+ });
+
+ it("exposes the API error message after a failed mutation", async () => {
+ vi.mocked(employeeApi.updateEmployeeOrder).mockRejectedValue(
+ new Error("Server unavailable"),
+ );
+
+ const { Wrapper } = createWrapper();
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "2" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error?.message).toBe("Server unavailable");
+ });
+
+ it("invalidates the employees query on settled (success path)", async () => {
+ vi.mocked(employeeApi.updateEmployeeOrder).mockResolvedValue(undefined);
+
+ const { queryClient, Wrapper } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "2" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: EMPLOYEES_QUERY_KEY,
+ });
+ });
+
+ it("invalidates the employees query on settled (error path)", async () => {
+ vi.mocked(employeeApi.updateEmployeeOrder).mockRejectedValue(
+ new Error("fail"),
+ );
+
+ const { queryClient, Wrapper } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ const { result } = renderHook(() => useUpdateEmployeeOrder(), {
+ wrapper: Wrapper,
+ });
+
+ act(() => {
+ result.current.mutate({ activeId: "1", overId: "2" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: EMPLOYEES_QUERY_KEY,
+ });
+ });
+});
diff --git a/src/components/SortableRow.module.css b/src/components/SortableRow.module.css
new file mode 100644
index 00000000..1190c7a6
--- /dev/null
+++ b/src/components/SortableRow.module.css
@@ -0,0 +1,40 @@
+.dragHandle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ border: none;
+ background: transparent;
+ border-radius: 4px;
+ color: var(--color-gray-500, #6b7280);
+ cursor: grab;
+ line-height: 0;
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.dragHandle:hover {
+ color: var(--color-gray-900, #111827);
+ background-color: var(--color-gray-100, #f3f4f6);
+}
+
+.dragHandle:focus-visible {
+ outline: 2px solid var(--color-indigo-500, #6366f1);
+ outline-offset: 2px;
+}
+
+.dragHandle:active {
+ cursor: grabbing;
+}
+
+.dragging {
+ opacity: 0.4;
+ background-color: var(--color-indigo-50, #eef2ff);
+}
+
+.handleCell {
+ width: 40px;
+ padding: 0 8px;
+ vertical-align: middle;
+}
diff --git a/src/components/SortableRow.tsx b/src/components/SortableRow.tsx
new file mode 100644
index 00000000..75efb4b2
--- /dev/null
+++ b/src/components/SortableRow.tsx
@@ -0,0 +1,80 @@
+import type { CSSProperties } from "react";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+
+import type { Employee } from "../types/employee";
+import styles from "./SortableRow.module.css";
+
+interface Props {
+ employee: Employee;
+}
+
+function GripVertical() {
+ return (
+
+ );
+}
+
+export function SortableRow({ employee }: Props) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ setActivatorNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: employee.id });
+
+ const rowStyle: CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+ {/* Drag handle — only this element activates the drag sensor */}
+ |
+
+ |
+
+ {employee.name} |
+ {employee.role} |
+
+
+ {employee.walletAddress.slice(0, 6)}…
+ {employee.walletAddress.slice(-4)}
+
+ |
+ {employee.currency} |
+ {employee.salary.toLocaleString()} |
+
+ );
+}
diff --git a/src/hooks/useUpdateEmployeeOrder.ts b/src/hooks/useUpdateEmployeeOrder.ts
new file mode 100644
index 00000000..1f53b2d5
--- /dev/null
+++ b/src/hooks/useUpdateEmployeeOrder.ts
@@ -0,0 +1,72 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { arrayMove } from "@dnd-kit/sortable";
+
+import { updateEmployeeOrder } from "../services/employeeApi";
+import type { Employee, EmployeeOrderPayload } from "../types/employee";
+
+export const EMPLOYEES_QUERY_KEY = ["employees"] as const;
+
+export interface ReorderArgs {
+ activeId: string;
+ overId: string;
+}
+
+interface MutationContext {
+ previous: Employee[];
+}
+
+export function useUpdateEmployeeOrder() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ /**
+ * By the time mutationFn fires, onMutate has already applied the
+ * optimistic reorder to the cache — so we read the new order from
+ * the cache and build the minimal payload the API actually needs.
+ */
+ mutationFn: async () => {
+ const reordered =
+ queryClient.getQueryData(EMPLOYEES_QUERY_KEY) ?? [];
+
+ const payload: EmployeeOrderPayload[] = reordered.map((emp, i) => ({
+ id: emp.id,
+ newIndex: i,
+ }));
+
+ return updateEmployeeOrder(payload);
+ },
+
+ onMutate: async ({ activeId, overId }) => {
+ // Stop any in-flight refetch from overwriting our optimistic update.
+ await queryClient.cancelQueries({ queryKey: EMPLOYEES_QUERY_KEY });
+
+ const previous =
+ queryClient.getQueryData(EMPLOYEES_QUERY_KEY) ?? [];
+
+ const activeIndex = previous.findIndex((e) => e.id === activeId);
+ const overIndex = previous.findIndex((e) => e.id === overId);
+
+ if (activeIndex !== -1 && overIndex !== -1) {
+ queryClient.setQueryData(
+ EMPLOYEES_QUERY_KEY,
+ arrayMove(previous, activeIndex, overIndex),
+ );
+ }
+
+ return { previous };
+ },
+
+ onError: (_err, _vars, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ EMPLOYEES_QUERY_KEY,
+ context.previous,
+ );
+ }
+ },
+
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY });
+ },
+ });
+}
diff --git a/src/pages/EmployeeTable.module.css b/src/pages/EmployeeTable.module.css
new file mode 100644
index 00000000..9551a814
--- /dev/null
+++ b/src/pages/EmployeeTable.module.css
@@ -0,0 +1,70 @@
+.wrapper {
+ padding: 24px;
+}
+
+.title {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 16px;
+ color: var(--color-gray-900, #111827);
+}
+
+.tableContainer {
+ overflow-x: auto;
+ border: 1px solid var(--color-gray-200, #e5e7eb);
+ border-radius: 8px;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.table thead th {
+ background-color: var(--color-gray-50, #f9fafb);
+ color: var(--color-gray-500, #6b7280);
+ font-weight: 500;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ padding: 10px 12px;
+ text-align: left;
+ border-bottom: 1px solid var(--color-gray-200, #e5e7eb);
+}
+
+.table tbody tr {
+ border-bottom: 1px solid var(--color-gray-100, #f3f4f6);
+ background-color: var(--color-white, #ffffff);
+ transition: background-color 0.1s ease;
+}
+
+.table tbody tr:last-child {
+ border-bottom: none;
+}
+
+.table tbody tr:hover {
+ background-color: var(--color-gray-50, #f9fafb);
+}
+
+.table tbody td {
+ padding: 12px 12px;
+ color: var(--color-gray-700, #374151);
+ vertical-align: middle;
+}
+
+.error {
+ color: var(--color-red-600, #dc2626);
+ padding: 16px;
+}
+
+.skeleton {
+ padding: 24px;
+ color: var(--color-gray-400, #9ca3af);
+}
+
+.mutationError {
+ margin-top: 8px;
+ font-size: 0.8rem;
+ color: var(--color-red-600, #dc2626);
+}
diff --git a/src/pages/EmployeeTable.tsx b/src/pages/EmployeeTable.tsx
new file mode 100644
index 00000000..1e697be5
--- /dev/null
+++ b/src/pages/EmployeeTable.tsx
@@ -0,0 +1,113 @@
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import type { DragEndEvent } from "@dnd-kit/core";
+import {
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { useQuery } from "@tanstack/react-query";
+
+import { SortableRow } from "../components/SortableRow";
+import { fetchEmployees } from "../services/employeeApi";
+import {
+ EMPLOYEES_QUERY_KEY,
+ useUpdateEmployeeOrder,
+} from "../hooks/useUpdateEmployeeOrder";
+import styles from "./EmployeeTable.module.css";
+
+export default function EmployeeTable() {
+ const {
+ data: employees,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: EMPLOYEES_QUERY_KEY,
+ queryFn: fetchEmployees,
+ // Keep the stale snapshot in place while an optimistic update is live
+ staleTime: 30_000,
+ });
+
+ const { mutate: reorder, error: mutationError } = useUpdateEmployeeOrder();
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ }),
+ );
+
+ function onDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+
+ reorder({
+ activeId: String(active.id),
+ overId: String(over.id),
+ });
+ }
+
+ if (isLoading) {
+ return Loading employees…
;
+ }
+
+ if (isError || !employees) {
+ return (
+
+ Failed to load employees. Please try again.
+
+ );
+ }
+
+ const employeeIds = employees.map((e) => e.id);
+
+ return (
+
+
Employees
+
+ {mutationError && (
+
+ Failed to save order: {mutationError.message}. The previous order has
+ been restored.
+
+ )}
+
+
+
+
+
+
+ |
+ Name |
+ Role |
+ Wallet |
+ Currency |
+ Salary |
+
+
+
+
+ {employees.map((employee) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/services/employeeApi.ts b/src/services/employeeApi.ts
new file mode 100644
index 00000000..d4fb721d
--- /dev/null
+++ b/src/services/employeeApi.ts
@@ -0,0 +1,31 @@
+import type { Employee, EmployeeOrderPayload } from "../types/employee";
+
+const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? "/api";
+
+async function handleResponse(res: Response): Promise {
+ if (!res.ok) {
+ const message = await res.text().catch(() => res.statusText);
+ throw new Error(message || `HTTP ${res.status}`);
+ }
+}
+
+export async function fetchEmployees(): Promise {
+ const res = await fetch(`${BASE_URL}/employees`);
+ if (!res.ok) {
+ const message = await res.text().catch(() => res.statusText);
+ throw new Error(message || `HTTP ${res.status}`);
+ }
+ return res.json() as Promise;
+}
+
+export async function updateEmployeeOrder(
+ updates: EmployeeOrderPayload[],
+): Promise {
+ const res = await fetch(`${BASE_URL}/employees/order`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(updates),
+ });
+
+ await handleResponse(res);
+}
diff --git a/src/types/employee.ts b/src/types/employee.ts
new file mode 100644
index 00000000..66475504
--- /dev/null
+++ b/src/types/employee.ts
@@ -0,0 +1,15 @@
+export interface Employee {
+ id: string;
+ name: string;
+ role: string;
+ walletAddress: string;
+ currency: string;
+ salary: number;
+ orderIndex: number;
+}
+
+// Payload that the backend needs to reorder rows.
+export interface EmployeeOrderPayload {
+ id: string;
+ newIndex: number;
+}