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. +

+ )} + +
+ + + + + + + + + + + + + + {employees.map((employee) => ( + + ))} + + +
+ NameRoleWalletCurrencySalary
+
+
+
+ ); +} 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; +}