1. Project setup#

What this step does. Scaffolds a plain Vite + React + TypeScript app, adds Mantine and the three Palmyra frontend libraries, configures a dev-server proxy so /api lands on the SpringBoot backend, and sets up routing.

npm create vite@latest empmgmt-web -- --template react-ts
cd empmgmt-web

Install#

npm install react@19 react-dom@19 \
            @mantine/core@8 @mantine/hooks@8 @mantine/dates@8 \
            react-router-dom@7 react-toastify dayjs \
            @palmyralabs/palmyra-wire \
            @palmyralabs/rt-forms \
            @palmyralabs/rt-forms-mantine \
            @palmyralabs/template-tribble

@palmyralabs/template-tribble supplies SummaryGrid, DialogNewForm, and DialogEditForm — prebuilt templates that cut the repetitive grid / modal plumbing out of each page. This is the same pattern the thinxar/clinic reference uses.

Vite proxy#

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 3000, proxy: { '/api': 'http://localhost:8080' } }
});

App bootstrap#

// src/main.tsx
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import 'react-toastify/dist/ReactToastify.css';

import { createRoot } from 'react-dom/client';
import { MantineProvider } from '@mantine/core';
import { ToastContainer } from 'react-toastify';
import { StoreFactoryContext } from '@palmyralabs/palmyra-wire';
import AppStoreFactory from './wire/StoreFactory';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <MantineProvider>
    <StoreFactoryContext.Provider value={AppStoreFactory}>
      <App />
      <ToastContainer position="top-right" autoClose={3000} />
    </StoreFactoryContext.Provider>
  </MantineProvider>
);

Routing shell#

Each entity gets a list route and a view route — matching the clinic convention where the grid row click navigates into a detail / edit page:

// src/App.tsx
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import DepartmentGridPage from './pages/department/DepartmentGridPage';
import DepartmentViewPage from './pages/department/view/DepartmentViewPage';
import EmployeeGridPage   from './pages/employee/EmployeeGridPage';
import EmployeeViewPage   from './pages/employee/view/EmployeeViewPage';

export default function App() {
  return (
    <BrowserRouter>
      <nav style={{ padding: 16, display: 'flex', gap: 16 }}>
        <Link to="/departments">Departments</Link>
        <Link to="/employees">Employees</Link>
      </nav>
      <Routes>
        <Route path="/departments"           element={<DepartmentGridPage />} />
        <Route path="/departments/view/:id"  element={<DepartmentViewPage />} />
        <Route path="/employees"             element={<EmployeeGridPage />} />
        <Route path="/employees/view/:id"    element={<EmployeeViewPage />} />
      </Routes>
    </BrowserRouter>
  );
}

Folder layout per entity#

Following the clinic’s masterdata pattern — one folder per entity, with a view/ subfolder holding the detail page and its edit dialog:

src/pages/
  department/
    DepartmentGridPage.tsx   ← master list + "new" modal
    DepartmentNewForm.tsx    ← DialogNewForm
    view/
      DepartmentViewPage.tsx ← detail page shell
      DepartmentViewForm.tsx ← read-only view form (uses PalmyraViewForm)
      DepartmentEditForm.tsx ← DialogEditForm (reuses the new-form field set)
  employee/
    EmployeeGridPage.tsx
    EmployeeNewForm.tsx
    view/
      EmployeeViewPage.tsx
      EmployeeViewForm.tsx
      EmployeeEditForm.tsx

Run npm run dev and confirm the page loads on http://localhost:3000. The next step wires the store factory so those page components can actually talk to the backend.