===== CODEDUMP =====
MODE: all
FORMAT: ai
GENERATED_AT: 2026-08-04T12:52:55.969Z

===== PROJECT: woo-standalone-ui-frontend =====

===== PROJECT OVERVIEW: woo-standalone-ui-frontend =====
PROJECT: woo-standalone-ui-frontend
FILES_MATCHED: 56
INCLUDE_EXTS: .js,.cjs,.mjs,.jsx,.ts,.tsx,.json,.html,.md,.mdx,.yml,.yaml,.toml,.graphql,.gql,.prisma,.sql,.vue,.svelte,.astro
SKIP_DIRS: node_modules,.git,dist,build,coverage,.next,.nuxt,.svelte-kit,.astro,.turbo,.cache,.parcel-cache,.vite,tmp,temp,logs,public
TREE: compact
.devcontainer/devcontainer.json
.gitignore
.prettierrc.json
.vscode/launch.json
.vscode/settings.json
bitbucket-pipelines.yml
docker-compose.yaml
Dockerfile
eslint.config.js
index.html
package.json
src/api.js
src/App/App.jsx
src/components/beheer/AppKleuren/AppKleuren.jsx
src/components/beheer/Audit/Audit.jsx
src/components/beheer/CustomEntities/CustomEntities.jsx
src/components/beheer/ExportKolommen/ExportKolommen.jsx
src/components/beheer/Gronden/Gronden.jsx
src/components/beheer/Lijsten/Lijsten.jsx
src/components/beheer/MaakAccount/MaakAccount.jsx
src/components/beheer/TonicEntities/TonicEntities.jsx
src/components/beheer/Zaken/Zaken.jsx
src/components/beoordelen/RechterpaneelGronden/RechterpaneelGronden.jsx
src/components/beoordelen/RechterpaneelSnapshots/RechterpaneelSnapshots.jsx
src/components/beoordelen/RechterpaneelTags/RechterpaneelTags.jsx
src/components/beoordelen/Statistieken/Statistieken.jsx
src/components/beoordelen/useBeoordelenData.js
src/components/beoordelen/useDocumentBuren.js
src/components/beoordelen/useDocumentSneltoetsen.js
src/components/beoordelen/useVerlatenGuard.js
src/components/beoordelen/VerlatenPopup/VerlatenPopup.jsx
src/components/beoordelen/VersiesPopup/VersiesPopup.jsx
src/components/beoordelen/ViewerVlak/ViewerVlak.jsx
src/components/beoordelen/WerkBalk/WerkBalk.jsx
src/components/documenten/DocumentenTabel/DocumentenTabel.jsx
src/components/documenten/DocumentenWerkbalk/DocumentenWerkbalk.jsx
src/components/documenten/Paginering/Paginering.jsx
src/components/documenten/useDocumentenData.js
src/components/documenten/ZaakStatus/ZaakStatus.jsx
src/components/zaken/FaseBalk/FaseBalk.jsx
src/main.jsx
src/pages/Annotaties/Annotaties.jsx
src/pages/Beheer/Beheer.jsx
src/pages/Beoordelen/Beoordelen.jsx
src/pages/Documenten/Documenten.jsx
src/pages/Inloggen/Inloggen.jsx
src/pages/Zaken/Zaken.jsx
src/viewer/laadWebViewer.js
src/viewer/useWebViewer.js
src/viewer/useWebViewerAnnotations.js
src/viewer/useWebViewerDocument.js
src/viewer/useWebViewerInit.js
src/viewer/useWebViewerStatistieken.js
src/viewer/webViewerEventListeners.js
src/viewer/webviewerHelpers.js
vite.config.js


===== FILE: woo-standalone-ui-frontend/.devcontainer/devcontainer.json =====
META: lines=35 bytes=1150
----- CODE START -----
{
    "name": "Lakstraat Frontend",
    "image": "node:24-alpine3.23",
    "customizations": {
        "vscode": {
            "extensions": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
            "settings": {
                "editor.formatOnSave": true,
                "[javascript]": {
                    "editor.defaultFormatter": "esbenp.prettier-vscode"
                },
                "[javascriptreact]": {
                    "editor.defaultFormatter": "esbenp.prettier-vscode"
                }
            }
        }
    },
    "containerEnv": {
        "NODE_ENV": "development",
        "CHOKIDAR_USEPOLLING": "true",
        "CHOKIDAR_INTERVAL": "100"
    },
    "postCreateCommand": "npm ci",
    "forwardPorts": [5173],
    "portsAttributes": {
        "5173": {
            "label": "Vite dev server",
            "onAutoForward": "openBrowser"
        }
    },
    "remoteUser": "node",
    "mounts": ["source=${localWorkspaceFolder}/../lakstraat-data,target=/lakstraat,type=bind"],
    "runArgs": ["--env-file", ".devcontainer/dev.environment.env", "--network", "lakstraat-dev"]
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/.gitignore =====
META: lines=51 bytes=711
----- CODE START -----
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
#     https://www.atlassian.com/git/tutorials/saving-changes/gitignore
.env
# Node artifact files
node_modules/
dist/

# Compiled Java class files
*.class

# Compiled Python bytecode
*.py[cod]

# Log files
*.log

# Package files
*.jar

# Maven
target/
dist/

# JetBrains IDE
.idea/

# Unit test reports
TEST*.xml

# Generated by MacOS
.DS_Store

# Generated by Windows
Thumbs.db

# Applications
*.app
*.exe
*.war

# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv

.devcontainer/dev.environment.env
----- CODE END -----


===== FILE: woo-standalone-ui-frontend/.prettierrc.json =====
META: lines=9 bytes=140
----- CODE START -----
{
    "trailingComma": "es5",
    "tabWidth": 4,
    "semi": true,
    "singleQuote": false,
    "printWidth": 100,
    "endOfLine": "lf"
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/.vscode/launch.json =====
META: lines=29 bytes=644
----- CODE START -----
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Frontend: npm run dev",
      "type": "node-terminal",
      "request": "launch",
      "command": "npm run dev -- --host 0.0.0.0 --port 5173",
      "cwd": "${workspaceFolder}"
    },
    {
      "name": "Frontend: Chrome http://localhost:5173",
      "type": "pwa-chrome",
      "request": "launch",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}"
    }
  ],
  "compounds": [
    {
      "name": "Frontend full debug",
      "configurations": [
        "Frontend: npm run dev",
        "Frontend: Chrome http://localhost:5173"
      ]
    }
  ]
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/.vscode/settings.json =====
META: lines=15 bytes=409
----- CODE START -----
{
    "editor.formatOnSave": true,
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "eslint.useFlatConfig": true,
    "eslint.validate": ["javascript", "javascriptreact"],
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": "explicit"
    }
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/bitbucket-pipelines.yml =====
META: lines=89 bytes=2728
IMPORTS: ./package.json
----- CODE START -----
image: node:24

options:
  docker: true
  max-time: 45

pipelines:
  custom:
    build-and-push:
      - step:
          name: Build & Verify
          caches:
            - node
          script:
            - npm ci
            - npm run build

      - step:
          name: Create Docker image
          caches:
            - node
            - docker
          script:
            - export HARBOR_URL=harbor-kfc.ilionx-ocp.com
            - export HARBOR_USERNAME=$HARBOR_USERNAME
            - export HARBOR_PASSWORD=$HARBOR_PASSWORD

            - export COMMIT=$(git show -s --format=%h $BITBUCKET_COMMIT)
            - export PACKAGE_VERSION=$(node -p -e "require('./package.json').version")
            - export VERSION=$PACKAGE_VERSION-$COMMIT

            - npm install -g json
            - json -I -f package.json -e "this.version='$VERSION'"

            - echo "Building upload-service $VERSION"

            - |
                docker build \
                  --tag "$HARBOR_URL/aux/lakstraat/ui/woo-standalone-ui-frontend:$VERSION" \
                  --file Dockerfile \
                  .

            - docker login -u "$HARBOR_USERNAME" -p "$HARBOR_PASSWORD" "$HARBOR_URL"
            - docker push "$HARBOR_URL/aux/lakstraat/ui/woo-standalone-ui-frontend:$VERSION"
            - docker logout


    build-and-push-temp:
      - step:
          name: Build & Verify (Temp Harbor)
          caches:
            - node
          script:
            - npm ci
            - npm run build

      - step:
          name: Create Docker image (Temp Harbor)
          caches:
            - node
            - docker
          script:
            - export HARBOR_URL=$HARBOR_URL_TEMP
            - export HARBOR_USERNAME=$HARBOR_USERNAME_TEMP
            - export HARBOR_PASSWORD=$HARBOR_PASSWORD_TEMP

            - export COMMIT=$(git show -s --format=%h $BITBUCKET_COMMIT)
            - export PACKAGE_VERSION=$(node -p -e "require('./package.json').version")
            - export VERSION=$PACKAGE_VERSION-$COMMIT

            - npm install -g json
            - json -I -f package.json -e "this.version='$VERSION'"

            - echo "Building upload-service $VERSION"

            - |
                docker build \
                  --tag "$HARBOR_URL/aux/lakstraat/ui/woo-standalone-ui-frontend:$VERSION" \
                  --file Dockerfile \
                  .

            - docker login -u "$HARBOR_USERNAME" -p "$HARBOR_PASSWORD" "$HARBOR_URL"
            - docker push "$HARBOR_URL/aux/lakstraat/ui/woo-standalone-ui-frontend:$VERSION"
            - docker logout


definitions:
  caches:
    node: node_modules
----- CODE END -----


===== FILE: woo-standalone-ui-frontend/docker-compose.yaml =====
META: lines=220 bytes=6815
----- CODE START -----
name: lakstraat-local

networks:
  lakstraat-dev:
    name: lakstraat-dev
    driver: bridge

volumes:
  pg-data:
  mongo-data:

services:
  pg:
    image: postgres:16
    container_name: pg
    restart: unless-stopped
    networks: [lakstraat-dev]
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-ddidb}
      POSTGRES_USER: ${POSTGRES_USER:-ddidb}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ddidb}
    ports:
      - "${PG_HOST_PORT:-5432}:5432"
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test:
        [
          "CMD-SHELL",
          "pg_isready -U ${POSTGRES_USER:-ddidb} -d ${POSTGRES_DB:-ddidb}",
        ]
      interval: 10s
      timeout: 5s
      retries: 10

  rabbitmq:
    image: rabbitmq:3-management
    container_name: rabbitmq
    restart: unless-stopped
    networks:
      - lakstraat-dev
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-user}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-password}
    ports:
      - "${RABBITMQ_HOST_PORT:-5672}:5672"
      - "${RABBITMQ_MGMT_HOST_PORT:-15672}:15672"
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 5s
      retries: 10

  mongo:
    image: mongo:8.0
    container_name: mongo
    restart: unless-stopped
    networks: [lakstraat-dev]
    ports:
      - "${MONGO_HOST_PORT:-27017}:27017"
    volumes:
      - mongo-data:/data/db

  state-log:
    build:
      context: ../state-log
    container_name: state-log
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ../state-log/.devcontainer/dev.environment.env
    environment:
      PORT: ${STATE_LOG_PORT:-3000}
      DATABASE_CONNECTION_STRING: postgresql://${POSTGRES_USER:-ddidb}:${POSTGRES_PASSWORD:-ddidb}@pg:5432/${POSTGRES_DB:-ddidb}
      AUDIT_LOGGING_QUEUE_CONNECTION_STRING: amqp://${RABBITMQ_USER:-user}:${RABBITMQ_PASS:-password}@rabbitmq:5672
    ports:
      - "${STATE_LOG_HOST_PORT:-3000}:${STATE_LOG_PORT:-3000}"
    depends_on:
      pg:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy

  orchestrator:
    build:
      context: ../woo-standalone-orchestrator
    container_name: orchestrator
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ../woo-standalone-orchestrator/.devcontainer/dev.environment.env
    environment:
      PORT: ${ORCHESTRATOR_PORT:-4100}
      PG_DATABASE_CONNECTION_STRING: postgresql://${POSTGRES_USER:-ddidb}:${POSTGRES_PASSWORD:-ddidb}@pg:5432/${POSTGRES_DB:-ddidb}
      RABBITMQ_URL: amqp://${RABBITMQ_USER:-user}:${RABBITMQ_PASS:-password}@rabbitmq:5672
      STATE_LOG_URL: http://state-log:${STATE_LOG_PORT:-3000}
    ports:
      - "${ORCHESTRATOR_HOST_PORT:-4100}:${ORCHESTRATOR_PORT:-4100}"
    depends_on:
      pg:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
      state-log:
        condition: service_started

  upload-service:
    build:
      context: ../upload-service
    container_name: upload-service
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ../upload-service/.devcontainer/dev.environment.env
    environment:
      PORT: ${UPLOAD_PORT:-4000}
      PG_DATABASE_CONNECTION_STRING: postgresql://${POSTGRES_USER:-ddidb}:${POSTGRES_PASSWORD:-ddidb}@pg:5432/${POSTGRES_DB:-ddidb}
      RABBITMQ_CONNECTION_STRING: amqp://${RABBITMQ_USER:-user}:${RABBITMQ_PASS:-password}@rabbitmq:5672
      STATE_LOG_API_URL: http://state-log:${STATE_LOG_PORT:-3000}
      DOCUMENT_STORAGE_BACKEND: contentstore
      CONTENT_STORE_ROOT: /lakstraat/store
      BRON_ROOT: /lakstraat/bron
    ports:
      - "${UPLOAD_HOST_PORT:-4001}:${UPLOAD_PORT:-4000}"
    depends_on:
      pg:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
      state-log:
        condition: service_started
    volumes:
      - type: bind
        source: ${LAKSTRAAT_DATA_ROOT}
        target: /lakstraat

  drs-helper:
    build:
      context: ../drs-helper
    container_name: drs-helper
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ../drs-helper/.devcontainer/dev.environment.env
    ports:
      - "${DRS_HELPER_HOST_PORT:-3010}:3010"
    volumes:
      - type: bind
        source: ${DOC_DIRECT_ROOT}
        target: /docdirect

  drs:
    build:
      context: ../document-redaction-services-new
    container_name: drs
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ../document-redaction-services-new/.devcontainer/dev.environment.env
    environment:
      PORT: ${DRS_PORT:-3000}
      DATABASE_CONNECTION_STRING: mongodb://mongo:27017/docDirectDocker
      PG_DATABASE_CONNECTION_STRING: postgresql://${POSTGRES_USER:-ddidb}:${POSTGRES_PASSWORD:-ddidb}@pg:5432/${POSTGRES_DB:-ddidb}
      STATE_LOG_API_URL: http://state-log:${STATE_LOG_PORT:-3000}
      RABBITMQ_CONN_STRING: amqp://${RABBITMQ_USER:-user}:${RABBITMQ_PASS:-password}@rabbitmq:5672
      KUBEMQ_SERVER_URL: rabbitmq
      DRS_HELPER_URL: http://drs-helper:3010
      CONTENT_STORE_ROOT: /lakstraat/store
      BRON_ROOT: /lakstraat/bron
    ports:
      - "${DRS_HOST_PORT:-3002}:${DRS_PORT:-3000}"
    depends_on:
      pg:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
      mongo:
        condition: service_started
      state-log:
        condition: service_started
      drs-helper:
        condition: service_started
    volumes:
      - type: bind
        source: ${LAKSTRAAT_DATA_ROOT}
        target: /lakstraat
      - type: bind
        source: ${DOC_DIRECT_ROOT}
        target: /docdirect

  backend:
    build:
      context: .
    container_name: backend
    restart: unless-stopped
    networks: [lakstraat-dev]
    env_file:
      - ./.devcontainer/dev.environment.env
    environment:
      MODE: live
      PORT: ${BACKEND_PORT:-4000}
      PG_DATABASE_CONNECTION_STRING: postgresql://${POSTGRES_USER:-ddidb}:${POSTGRES_PASSWORD:-ddidb}@pg:5432/${POSTGRES_DB:-ddidb}
      STATE_LOG_URL: http://state-log:${STATE_LOG_PORT:-3000}
      ORCHESTRATOR_URL: http://orchestrator:${ORCHESTRATOR_PORT:-4100}
      CONTENT_STORE_ROOT: /lakstraat/store
      BRON_ROOT: /lakstraat/bron
    ports:
      - "${BACKEND_HOST_PORT:-4000}:${BACKEND_PORT:-4000}"
    depends_on:
      pg:
        condition: service_healthy
      state-log:
        condition: service_started
      orchestrator:
        condition: service_started
    volumes:
      - type: bind
        source: ${LAKSTRAAT_DATA_ROOT}
        target: /lakstraat

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/Dockerfile =====
META: lines=27 bytes=567
----- CODE START -----
# ---- Build Stage (compile React/Vite → dist) ----
FROM node:24-alpine3.23 AS builder

RUN apk update && apk upgrade --no-cache

WORKDIR /app

COPY package*.json ./

RUN npm ci

COPY . .
RUN npm run build

# ---- Runtime Stage (serve static assets, unprivileged) ----
FROM nginxinc/nginx-unprivileged:1.27-alpine

USER root
RUN apk update && apk upgrade --no-cache
USER nginx

COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html

EXPOSE 8080

CMD ["nginx", "-g", "daemon off;"]
----- CODE END -----


===== FILE: woo-standalone-ui-frontend/eslint.config.js =====
META: lines=34 bytes=969
IMPORTS: @eslint/js, eslint-plugin-react-hooks, eslint-plugin-react-refresh, globals
----- CODE START -----
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';

export default [
    {
        ignores: ['dist/**', 'node_modules/**'],
    },
    {
        files: ['**/*.{js,jsx}'],
        languageOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            parserOptions: {
                ecmaFeatures: { jsx: true },
            },
            globals: {
                ...globals.browser,
            },
        },
        plugins: {
            'react-hooks': reactHooks,
            'react-refresh': reactRefresh,
        },
        rules: {
            ...js.configs.recommended.rules,
            ...reactHooks.configs.recommended.rules,
            'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
            'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
        },
    },
];

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/index.html =====
META: lines=14 bytes=371
----- CODE START -----
<!doctype html>
<html lang="nl">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Lakstraat</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="/api/config.js"></script>
        <script type="module" src="/src/main.jsx"></script>
    </body>
</html>

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/package.json =====
META: lines=28 bytes=688
----- CODE START -----
{
    "name": "lakstraat-frontend",
    "version": "0.1.0",
    "private": true,
    "type": "module",
    "scripts": {
        "dev": "vite",
        "lint": "eslint .",
        "lint:fix": "eslint . --fix",
        "build": "vite build",
        "preview": "vite preview"
    },
    "dependencies": {
        "react": "18.3.1",
        "react-dom": "18.3.1",
        "react-router-dom": "6.30.0"
    },
    "devDependencies": {
        "@eslint/js": "9.31.0",
        "@vitejs/plugin-react": "4.3.4",
        "eslint": "9.31.0",
        "eslint-plugin-react-hooks": "5.1.0",
        "eslint-plugin-react-refresh": "0.4.20",
        "globals": "15.9.0",
        "vite": "5.4.19"
    }
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/api.js =====
META: lines=220 bytes=9111
EXPORTS: api, FASE_LABELS, VERBORGEN_IN_FILTER, VERBORGEN_IN_VOORTGANG
SYMBOLS: fn:req
----- CODE START -----
// Dunne API-client naar de BFF. Alle fouten worden als Error met bericht gegooid.
async function req(pad, opties = {}) {
    const res = await fetch(pad, {
        headers: {
            "content-type": "application/json",
            ...(opties.headers || {}),
        },
        ...opties,
        body: opties.body ? JSON.stringify(opties.body) : undefined,
    });
    if (res.status === 401 && !pad.startsWith("/api/auth/")) {
        window.dispatchEvent(new CustomEvent("lakstraat:aanmelden-vereist"));
        throw new Error("Niet aangemeld");
    }
    if (!res.ok) {
        const detail = await res.json().catch(() => ({}));
        throw new Error(detail.message || `Fout ${res.status}`);
    }
    return res.json();
}

export const api = {
    auth: {
        config: () => req("/api/auth/config"),
        me: () => req("/api/auth/me"),
        login: (gebruikersnaam, wachtwoord) =>
            req("/api/auth/login", {
                method: "POST",
                body: { gebruikersnaam, wachtwoord },
            }),
        logout: () => req("/api/auth/logout", { method: "POST" }),
    },
    zaken: () => req("/api/zaken"),
    zaak: (caseId) => req(`/api/zaken/${caseId}`),
    maakZaak: (caseId, omschrijving) =>
        req("/api/zaken", {
            method: "POST",
            body: { case_id: caseId, omschrijving },
        }),
    documenten: (caseId, params) =>
        req(`/api/zaken/${caseId}/documenten?` + new URLSearchParams(params)),
    documentBuren: (caseId, docId, params) =>
        req(
            `/api/zaken/${encodeURIComponent(caseId)}/documenten/${encodeURIComponent(docId)}/buren` +
                (params ? `?${params}` : "")
        ),
    weigeringsgronden: () => req("/api/weigeringsgronden"),
    metadata: (docId, caseId, wijzigingen) =>
        req(`/api/documenten/${docId}/metadata`, {
            method: "PATCH",
            body: { caseId, wijzigingen },
        }),
    annotaties: (docId) => req(`/api/documenten/${docId}/annotaties`),
    bewaarAnnotaties: (docId, xfdf) =>
        req(`/api/documenten/${docId}/annotaties`, {
            method: "PUT",
            body: { xfdf },
        }),
    snapshots: (docId) => req(`/api/documenten/${docId}/snapshots`),
    maakSnapshot: (docId) => req(`/api/documenten/${docId}/snapshots`, { method: "POST" }),
    herstelSnapshot: (docId, snapshotId) =>
        req(`/api/documenten/${docId}/snapshots/${snapshotId}/herstel`, {
            method: "POST",
        }),
    definitief: (docId, reden) =>
        req(`/api/documenten/${docId}/definitief`, {
            method: "POST",
            body: { reden },
        }),
    batch: (caseId, actie, documentIds, filter) =>
        req(`/api/zaken/${caseId}/batch`, {
            method: "POST",
            body: filter ? { actie, filter } : { actie, documentIds },
        }),
    audit: (doel) => req(`/api/audit?` + new URLSearchParams(doel ? { doel } : {})),
    tags: (caseId) => req(`/api/zaken/${caseId}/tags`),
    muteerTags: (caseId, documentIds, voegToe, verwijder, filter) =>
        req(`/api/zaken/${caseId}/tags`, {
            method: "POST",
            body: filter ? { filter, voegToe, verwijder } : { documentIds, voegToe, verwijder },
        }),
    documentTags: (docId) => req(`/api/documenten/${docId}/tags`),
    uploads: async (caseId, bestanden) => {
        const fd = new FormData();
        for (const bestand of bestanden) fd.append("bestanden", bestand, bestand.name);
        const res = await fetch(`/api/zaken/${caseId}/uploads`, {
            method: "POST",
            body: fd,
        });
        if (res.status === 401) {
            window.dispatchEvent(new CustomEvent("lakstraat:aanmelden-vereist"));
            throw new Error("Niet aangemeld");
        }
        if (!res.ok) {
            const detail = await res.json().catch(() => ({}));
            throw new Error(detail.message || `Fout ${res.status}`);
        }
        return res.json();
    },
    verwijderDocument: (docId) =>
        req(`/api/documenten/${docId}`, {
            method: "DELETE",
        }),
    // Bepaalt welke documenten geraakt worden, zonder te verwijderen.
    verwijderDocumentControle: (docId) =>
        req(`/api/documenten/${docId}?dryRun=true`, {
            method: "DELETE",
        }),

    annotatieOverzicht: (caseId, { fase = "converteren", ververs = false } = {}) =>
        req(
            `/api/zaken/${encodeURIComponent(caseId)}/annotaties/overzicht` +
                `?fase=${encodeURIComponent(fase)}${ververs ? "&ververs=true" : ""}`
        ),

    zaakStatus: (caseId) => req(`/api/zaken/${encodeURIComponent(caseId)}/status`),

    quarantaineOpnieuw: (caseId, fase = null) =>
        req(`/api/zaken/${encodeURIComponent(caseId)}/quarantaine/opnieuw`, {
            method: "POST",
            body: fase ? { fase } : {},
        }),

    verwijderZaakControle: (caseId) =>
        req(`/api/zaken/${encodeURIComponent(caseId)}?dryRun=true`, { method: "DELETE" }),
    verwijderZaak: (caseId) =>
        req(`/api/zaken/${encodeURIComponent(caseId)}?bevestig=${encodeURIComponent(caseId)}`, {
            method: "DELETE",
        }),

    verplaatsDocumentenControle: (documentIds, doelZaak, metVarianten = true) =>
        req(`/api/documenten/verplaats?dryRun=true`, {
            method: "POST",
            body: { documentIds, doelZaak, metVarianten },
        }),
    verplaatsDocumenten: (documentIds, doelZaak, metVarianten = true) =>
        req(`/api/documenten/verplaats`, {
            method: "POST",
            body: { documentIds, doelZaak, metVarianten },
        }),

    beheer: {
        weigeringsgronden: () => req("/api/beheer/weigeringsgronden"),
        bewaarWeigeringsgronden: (gronden, summaries) =>
            req("/api/beheer/weigeringsgronden", {
                method: "PUT",
                body: { gronden, summaries },
            }),
        appConfig: () => req("/api/beheer/app-config"),
        bewaarAppConfig: (config) =>
            req("/api/beheer/app-config", {
                method: "PUT",
                body: { config },
            }),
        lijst: (type) => req(`/api/beheer/lijsten/${type}`),
        lijstActie: (type, actie, text, refusalGround) =>
            req(`/api/beheer/lijsten/${type}/${actie}`, {
                method: "POST",
                body: { text, refusalGround },
            }),
        zaken: () => req("/api/beheer/zaken"),
        bewaarZaakConfig: (caseId, config) =>
            req(`/api/beheer/zaken/${caseId}/config`, {
                method: "PUT",
                body: { config },
            }),
        zaakActie: (caseId, actie) =>
            req(`/api/beheer/zaken/${caseId}/${actie}`, { method: "POST" }),
        tonicApiEntities: () => req("/api/beheer/tonic-entities/api"),
        tonicEntities: () => req("/api/beheer/tonic-entities"),
        bewaarTonicEntities: (entities) =>
            req("/api/beheer/tonic-entities", {
                method: "POST",
                body: entities,
            }),
        tonicEntityMappings: () => req("/api/beheer/tonic-entities/mapping"),
        bewaarTonicEntityMappings: (mappings) =>
            req("/api/beheer/tonic-entities/mapping", {
                method: "POST",
                body: mappings,
            }),
        exportKolommen: () => req("/api/beheer/export-kolommen"),
        bewaarExportKolommen: (kolommen) =>
            req("/api/beheer/export-kolommen", {
                method: "PUT",
                body: { kolommen },
            }),
        customEntities: () => req("/api/beheer/custom-entities"),
        bewaarCustomEntities: ({ actief, entities }) =>
            req("/api/beheer/custom-entities", {
                method: "PUT",
                body: { actief, entities },
            }),
        maakAccountVoorbeeld: ({ naam, gebruikersnaam, wachtwoord, rollen }) =>
            req("/api/beheer/accounts/preview", {
                method: "POST",
                body: { naam, gebruikersnaam, wachtwoord, rollen },
            }),
    },
};

// ai_gesuggereerd bestaat in de praktijk niet als aparte fase: documenten gaan
// van geconverteerd naar in_beoordeling. De fase blijft in FASE_LABELS staan
// zodat een document met die waarde nog steeds een nette naam krijgt in de
// tabel, maar hij hoort niet in het filter of in de voortgangsbalk.
export const VERBORGEN_IN_FILTER = ["ai_gesuggereerd"];

// In de voortgangsbalk krijgt geexporteerd geen eigen segment. De documenten
// tellen wel mee in de vulling van het laatste segment; zie FaseBalk.
export const VERBORGEN_IN_VOORTGANG = ["ai_gesuggereerd", "geexporteerd"];

export const FASE_LABELS = {
    ingelezen: "Ingelezen",
    geconverteerd: "Geconverteerd",
    ai_gesuggereerd: "AI-suggesties",
    in_beoordeling: "In beoordeling",
    gelakt: "Definitief gelakt",
    geexporteerd: "Geëxporteerd",
};

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/App/App.jsx =====
META: lines=179 bytes=7268
IMPORTS: ../api.js, ../pages/Annotaties/Annotaties.jsx, ../pages/Beheer/Beheer.jsx, ../pages/Beoordelen/Beoordelen.jsx, ../pages/Documenten/Documenten.jsx, ../pages/Inloggen/Inloggen.jsx, ../pages/Zaken/Zaken.jsx, ./App.module.css, react, react-router-dom
EXPORTS: App
SYMBOLS: const-fn:isBeheerder, const-fn:naar401, const-fn:onKeyDown, const-fn:uitloggen
----- CODE START -----
import React, { useCallback, useEffect, useState } from "react";
import { Routes, Route, NavLink, Navigate, useLocation } from "react-router-dom";
import Zaken from "../pages/Zaken/Zaken.jsx";
import Documenten from "../pages/Documenten/Documenten.jsx";
import Annotaties from "../pages/Annotaties/Annotaties.jsx";
import Beoordelen from "../pages/Beoordelen/Beoordelen.jsx";
import Beheer from "../pages/Beheer/Beheer.jsx";
import Inloggen from "../pages/Inloggen/Inloggen.jsx";
import { api } from "../api.js";
import styles from "./App.module.css";

export default function App() {
    const [authConfig, setAuthConfig] = useState(null);
    const [ik, setIk] = useState(null);
    const [klaar, setKlaar] = useState(false);
    const [sidebarOpen, setSidebarOpen] = useState(false);
    const location = useLocation();

    const laadIk = useCallback(async () => {
        try {
            setIk(await api.auth.me());
        } catch {
            setIk(null);
        }
        setKlaar(true);
    }, []);

    useEffect(() => {
        api.auth
            .config()
            .then(setAuthConfig)
            .catch(() => setAuthConfig({ mode: "none" }));
        laadIk();
        const naar401 = () => setIk(null);
        window.addEventListener("lakstraat:aanmelden-vereist", naar401);
        return () => window.removeEventListener("lakstraat:aanmelden-vereist", naar401);
    }, [laadIk]);

    useEffect(() => {
        const onKeyDown = (e) => {
            if (e.key === "Escape") setSidebarOpen(false);
        };
        window.addEventListener("keydown", onKeyDown);
        return () => window.removeEventListener("keydown", onKeyDown);
    }, []);

    const uitloggen = async () => {
        await api.auth.logout().catch(() => {});
        setIk(null);
    };

    if (!authConfig || !klaar) return null;
    if (authConfig.mode !== "none" && !ik) {
        return <Inloggen config={authConfig} naAanmelden={laadIk} />;
    }

    const isBeheerder = (ik?.rollen || []).includes("beheerder");
    const isBeoordelenRoute = /^\/zaken\/[^/]+\/documenten\/[^/]+\/?$/.test(location.pathname);

    return (
        <div className={styles.app}>
            {sidebarOpen && (
                <div className={styles.sidebarOverlay} onClick={() => setSidebarOpen(false)} />
            )}

            <aside className={`${styles.zijbalk} ${sidebarOpen ? styles.open : ""}`}>
                <div className={styles.merk}>
                    <div className={styles.naam}>
                        Lak<span className={styles.gelakt}>straat</span>straat
                    </div>
                    <div className={styles.sub}>Documentredactie</div>
                </div>
                <nav className={styles.nav} onClick={() => setSidebarOpen(false)}>
                    <NavLink
                        to="/zaken"
                        className={({ isActive }) =>
                            `${styles.navLink} ${isActive ? styles.actief : ""}`
                        }
                    >
                        <span>Zaken</span>
                    </NavLink>
                    {isBeheerder && (
                        <NavLink
                            to="/beheer"
                            className={({ isActive }) =>
                                `${styles.navLink} ${isActive ? styles.actief : ""}`
                            }
                        >
                            <span>Beheer</span>
                        </NavLink>
                    )}
                </nav>
                <div className={styles.voet}>
                    Standalone · v0.1
                    <br />
                    {ik?.naam || "demo.gebruiker"}
                    {authConfig.mode !== "none" && (
                        <>
                            {" · "}
                            <a
                                href="#"
                                onClick={(e) => {
                                    e.preventDefault();
                                    uitloggen();
                                }}
                                className={styles.logoutLink}
                            >
                                Uitloggen
                            </a>
                        </>
                    )}
                </div>
            </aside>

            <main className={styles.hoofd}>
                {!isBeoordelenRoute && (
                    <header
                        style={{
                            display: "flex",
                            alignItems: "center",
                            gap: 12,
                            padding: "10px 16px",
                            borderBottom: "1px solid var(--lijn)",
                            background: "var(--vlak)",
                        }}
                    >
                        <button
                            className="klein secundair"
                            onClick={() => setSidebarOpen(true)}
                            aria-label="Open menu"
                        >
                            ☰ Menu
                        </button>
                        <div
                            style={{
                                fontFamily: "var(--font-kop)",
                                fontSize: "20px",
                                lineHeight: 1.1,
                                color: "var(--inkt)",
                            }}
                        >
                            Lak
                            <span
                                style={{
                                    background: "var(--lak)",
                                    color: "var(--lak)",
                                    padding: "0 6px",
                                    borderRadius: 2,
                                }}
                            >
                                straat
                            </span>
                            straat
                        </div>
                    </header>
                )}

                <div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
                    <Routes>
                        <Route path="/" element={<Navigate to="/zaken" replace />} />
                        <Route path="/zaken" element={<Zaken />} />
                        <Route path="/zaken/:caseId" element={<Documenten />} />
                        <Route
                            path="/zaken/:caseId/annotaties"
                            element={<Annotaties />}
                        />
                        <Route
                            path="/zaken/:caseId/documenten/:docId"
                            element={<Beoordelen onOpenMenu={() => setSidebarOpen(true)} />}
                        />
                        {isBeheerder && <Route path="/beheer" element={<Beheer />} />}
                        <Route path="*" element={<Navigate to="/zaken" replace />} />
                    </Routes>
                </div>
            </main>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/AppKleuren/AppKleuren.jsx =====
META: lines=128 bytes=5076
IMPORTS: ../../../api.js, ./AppKleuren.module.css, react
EXPORTS: AppKleuren
SYMBOLS: const-fn:bewaar, const-fn:zetPermanent, const-fn:zetWebviewer
----- CODE START -----
import React, { useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./AppKleuren.module.css";

export default function AppKleuren({ toon }) {
    const [config, setConfig] = useState({
        webviewer_colors: { ai: { hex: "#1876c9" }, handmatig: { hex: "#e0a800" } },
        permanent_redaction_colors: { fill: "#000000", overlay: "#ffffff", border: "#000000" },
    });
    const [laden, setLaden] = useState(true);
    const [vuil, setVuil] = useState(false);

    useEffect(() => {
        api.beheer
            .appConfig()
            .then((d) => {
                setConfig({
                    webviewer_colors: {
                        ai: { hex: d?.webviewer_colors?.ai?.hex || "#1876c9" },
                        handmatig: { hex: d?.webviewer_colors?.handmatig?.hex || "#e0a800" },
                    },
                    permanent_redaction_colors: {
                        fill: d?.permanent_redaction_colors?.fill || "#000000",
                        overlay: d?.permanent_redaction_colors?.overlay || "#ffffff",
                        border: d?.permanent_redaction_colors?.border || "#000000",
                    },
                });
            })
            .catch((e) => toon(e.message))
            .finally(() => setLaden(false));
    }, [toon]);

    const zetWebviewer = (key, hex) => {
        setConfig((prev) => ({
            ...prev,
            webviewer_colors: { ...prev.webviewer_colors, [key]: { hex } },
        }));
        setVuil(true);
    };

    const zetPermanent = (key, hex) => {
        setConfig((prev) => ({
            ...prev,
            permanent_redaction_colors: { ...prev.permanent_redaction_colors, [key]: hex },
        }));
        setVuil(true);
    };

    const bewaar = async () => {
        try {
            const saved = await api.beheer.bewaarAppConfig(config);
            setConfig(saved);
            setVuil(false);
            toon("App-kleuren opgeslagen.");
        } catch (e) {
            toon(e.message);
        }
    };

    if (laden) return <div className="leeg">Laden…</div>;

    return (
        <div className={`kaart ${styles.card}`}>
            <div className="werkbalk">
                <strong>Globale kleurinstellingen</strong>
                <div className="rek" />
                <button className="klein" disabled={!vuil} onClick={bewaar}>
                    Opslaan
                </button>
            </div>
            <div className={styles.content}>
                <div>
                    <strong className={styles.sectionTitle}>WebViewer (suggestie-kleuren)</strong>
                    <div className={styles.colorRow}>
                        <label>
                            AI
                            <input
                                type="color"
                                value={config.webviewer_colors.ai.hex}
                                onChange={(e) => zetWebviewer("ai", e.target.value)}
                                className={styles.colorInput}
                            />
                        </label>
                        <label>
                            Handmatig / blacklist
                            <input
                                type="color"
                                value={config.webviewer_colors.handmatig.hex}
                                onChange={(e) => zetWebviewer("handmatig", e.target.value)}
                                className={styles.colorInput}
                            />
                        </label>
                    </div>
                </div>
                <div>
                    <strong className={styles.sectionTitle}>
                        Permanente redactie (DRS redactionConfig)
                    </strong>
                    <div className={styles.colorRow}>
                        <label>
                            Achtergrondkleur (fill)
                            <input
                                type="color"
                                value={config.permanent_redaction_colors.fill}
                                onChange={(e) => zetPermanent("fill", e.target.value)}
                                className={styles.colorInput}
                            />
                        </label>
                        <label>
                            Tekstkleur (overlay)
                            <input
                                type="color"
                                value={config.permanent_redaction_colors.overlay}
                                onChange={(e) => zetPermanent("overlay", e.target.value)}
                                className={styles.colorInput}
                            />
                        </label>
                    </div>
                    <p className={styles.hint}>
                        Fill = achtergrond van de lak. Overlay = tekst die op de lak verschijnt
                        (weigeringsgrond).
                    </p>
                </div>
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/Audit/Audit.jsx =====
META: lines=67 bytes=2612
IMPORTS: ../../../api.js, ./Audit.module.css, react
EXPORTS: Audit
----- CODE START -----
import React, { useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./Audit.module.css";

export default function Audit() {
    const [regels, setRegels] = useState(null);
    const [doel, setDoel] = useState("");

    useEffect(() => {
        const timer = setTimeout(() => {
            api.audit(doel || undefined)
                .then(setRegels)
                .catch(() => setRegels([]));
        }, 250);
        return () => clearTimeout(timer);
    }, [doel]);

    return (
        <div className={`${styles.cardShell} ${styles.card}`}>
            <div className={styles.toolbar}>
                <input
                    type="text"
                    placeholder="Filter op doel (zaak- of document-id)…"
                    className={styles.filterInput}
                    value={doel}
                    onChange={(e) => setDoel(e.target.value)}
                    aria-label="Filter op doel"
                />
            </div>
            {!regels && <div className={styles.empty}>Laden…</div>}
            {regels && (
                <table className={styles.table}>
                    <thead>
                        <tr>
                            <th className={styles.timeCol}>Tijdstip</th>
                            <th className={styles.userCol}>Gebruiker</th>
                            <th className={styles.targetCol}>Doel</th>
                            <th>Actie</th>
                        </tr>
                    </thead>
                    <tbody>
                        {regels.map((r, i) => (
                            <tr key={i}>
                                <td className={styles.mono}>
                                    {r.action_timestamp
                                        ? new Date(r.action_timestamp).toLocaleString("nl-NL")
                                        : ""}
                                </td>
                                <td>{r.action_username}</td>
                                <td className={styles.mono}>{r.target}</td>
                                <td>{r.action_description}</td>
                            </tr>
                        ))}
                        {regels.length === 0 && (
                            <tr>
                                <td colSpan="4" className={styles.emptyCell}>
                                    Geen auditregels gevonden.
                                </td>
                            </tr>
                        )}
                    </tbody>
                </table>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/CustomEntities/CustomEntities.jsx =====
META: lines=348 bytes=16708
IMPORTS: ../../../api.js, ./CustomEntities.module.css, react
EXPORTS: CustomEntities
SYMBOLS: const-fn:bewaar, const-fn:wijzig, fn:patroonFout, fn:treffers
----- CODE START -----
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../../../api.js";
import styles from "./CustomEntities.module.css";

const LEGE_REGEL = {
    naam: "",
    patroon: "",
    entiteit: "",
    grond: "",
    case_sensitief: false,
    actief: true,
    toelichting: "",
};

/**
 * Compileert het patroon precies zoals DRS dat doet, zodat de melding hier
 * dezelfde is als de fout die anders pas in de AI-fase in de logs zou opduiken.
 */
function patroonFout(patroon, caseSensitief) {
    if (!String(patroon || "").trim()) return "Patroon is verplicht";
    try {
        new RegExp(patroon, caseSensitief ? "" : "i");
        return null;
    } catch (e) {
        return e.message;
    }
}

function treffers(patroon, caseSensitief, tekst) {
    if (!tekst || patroonFout(patroon, caseSensitief)) return [];
    try {
        const regex = new RegExp(patroon, caseSensitief ? "g" : "gi");
        return [...String(tekst).matchAll(regex)].map((m) => m[0]).slice(0, 12);
    } catch {
        return [];
    }
}

export default function CustomEntities({ toon }) {
    const [actief, setActief] = useState(true);
    const [bron, setBron] = useState("auto");
    const [regels, setRegels] = useState([]);
    const [gronden, setGronden] = useState([]);
    const [testTekst, setTestTekst] = useState("");
    const [bezig, setBezig] = useState(false);
    const [geladen, setGeladen] = useState(false);

    const laad = useCallback(() => {
        api.beheer
            .customEntities()
            .then((data) => {
                setActief(data?.actief !== false);
                setBron(data?.bron || "auto");
                setRegels(
                    (data?.entities || []).map((e) => ({
                        ...LEGE_REGEL,
                        ...e,
                        entiteit: e.entiteit || "",
                        toelichting: e.toelichting || "",
                    }))
                );
                setGeladen(true);
            })
            .catch((e) => {
                toon(`Custom entities ophalen mislukt: ${e.message}`);
                setGeladen(true);
            });
    }, [toon]);

    useEffect(() => laad(), [laad]);
    useEffect(() => {
        api.weigeringsgronden()
            .then(setGronden)
            .catch(() => setGronden([]));
    }, []);

    const wijzig = (index, veld, waarde) =>
        setRegels((vorige) =>
            vorige.map((regel, i) => (i === index ? { ...regel, [veld]: waarde } : regel))
        );

    const fouten = useMemo(
        () =>
            regels.map((r) => {
                if (!r.naam.trim()) return "Naam is verplicht";
                if (!r.entiteit.trim()) return "Entiteit is verplicht";
                if (!r.grond.trim()) return "Weigeringsgrond is verplicht";
                return patroonFout(r.patroon, r.case_sensitief);
            }),
        [regels]
    );

    const dubbeleNamen = useMemo(() => {
        const gezien = new Set();
        const dubbel = new Set();
        for (const r of regels) {
            const naam = r.naam.trim().toLowerCase();
            if (!naam) continue;
            if (gezien.has(naam)) dubbel.add(naam);
            gezien.add(naam);
        }
        return dubbel;
    }, [regels]);

    const kanOpslaan =
        geladen && !bezig && fouten.every((f) => !f) && dubbeleNamen.size === 0;

    const bewaar = async () => {
        if (!kanOpslaan) return;
        setBezig(true);
        try {
            const data = await api.beheer.bewaarCustomEntities({ actief, entities: regels });
            toon(
                `${data.aantal} patroon(en) opgeslagen. Toepassen staat ${
                    data.actief ? "aan" : "uit"
                }. DRS pikt de wijziging binnen een minuut op.`
            );
            laad();
        } catch (e) {
            toon(e.message);
        } finally {
            setBezig(false);
        }
    };

    return (
        <div className="kaart">
            <div className="werkbalk">
                <strong>Custom entities</strong>
                <div className="rek" />
                <label className={styles.schakelaar}>
                    <input
                        type="checkbox"
                        checked={actief}
                        onChange={(e) => setActief(e.target.checked)}
                    />
                    Patronen toepassen
                </label>
                <button
                    className="klein secundair"
                    onClick={() => setRegels((v) => [...v, { ...LEGE_REGEL }])}
                >
                    Regel toevoegen
                </button>
                <button className="klein" onClick={bewaar} disabled={!kanOpslaan}>
                    {bezig ? "Bezig…" : "Opslaan"}
                </button>
            </div>

            <div className={styles.body}>
                <p className={styles.uitleg}>
                    Regexpatronen waarmee DRS naast de AI-suggesties zelf lakvlakken voorstelt.
                    Staat <em>Patronen toepassen</em> uit, dan gebruikt DRS geen enkel patroon uit
                    deze lijst. Patronen zijn standaard niet case-sensitief, net als voorheen.
                    De entiteit verschijnt in de hover van het lakvlak ("AI RANG"), de
                    weigeringsgrond als label op het vlak zelf ("5.1.2.e") — net als bij de
                    Tonic-entiteiten. Na opslaan pikt DRS de wijziging binnen een minuut op;
                    nieuwe patronen komen pas in suggesties terecht als de AI-job opnieuw draait.
                </p>

                {bron === "configmap" && (
                    <p className={styles.bronMelding}>
                        DRS staat in deze omgeving op{" "}
                        <span className="mono">CUSTOM_ENTITIES_SOURCE=configmap</span> en leest de
                        patronen uit de ConfigMap. Wat je hier instelt wordt bewaard, maar niet
                        toegepast — bedoeld voor FileNet/BAW-omgevingen.
                    </p>
                )}
                {bron === "auto" && (
                    <p className={styles.bronMeldingZacht}>
                        Bron staat op <span className="mono">auto</span>: deze lijst is leidend
                        zolang de database bereikbaar is, met de ConfigMap als terugval.
                    </p>
                )}

                {!geladen && <p className={styles.leeg}>Laden…</p>}

                {geladen && regels.length === 0 && (
                    <p className={styles.leeg}>
                        Nog geen patronen. Neem de regels uit de ConfigMap
                        (<span className="mono">CUSTOM_ENTITIES</span>) hier over.
                    </p>
                )}

                {regels.length > 0 && (
                    <table className={styles.tabel}>
                        <thead>
                            <tr>
                                <th>Aan</th>
                                <th>Naam</th>
                                <th>Patroon (regex)</th>
                                <th>Entiteit</th>
                                <th>Grond</th>
                                <th>Aa</th>
                                <th />
                            </tr>
                        </thead>
                        <tbody>
                            {regels.map((regel, index) => {
                                const fout =
                                    fouten[index] ||
                                    (dubbeleNamen.has(regel.naam.trim().toLowerCase())
                                        ? "Deze naam komt dubbel voor"
                                        : null);
                                const gevonden = treffers(
                                    regel.patroon,
                                    regel.case_sensitief,
                                    testTekst
                                );

                                return (
                                    <React.Fragment key={index}>
                                        <tr className={regel.actief ? "" : styles.uit}>
                                            <td>
                                                <input
                                                    type="checkbox"
                                                    checked={regel.actief !== false}
                                                    onChange={(e) =>
                                                        wijzig(index, "actief", e.target.checked)
                                                    }
                                                    aria-label="Patroon actief"
                                                />
                                            </td>
                                            <td>
                                                <input
                                                    type="text"
                                                    value={regel.naam}
                                                    placeholder="bv. Kentekens"
                                                    onChange={(e) =>
                                                        wijzig(index, "naam", e.target.value)
                                                    }
                                                    aria-label="Naam"
                                                />
                                            </td>
                                            <td>
                                                <input
                                                    type="text"
                                                    className={`mono ${fout ? styles.foutVeld : ""}`}
                                                    value={regel.patroon}
                                                    placeholder="\b[A-Z]{2}-\d{2}-[A-Z]{2}\b"
                                                    onChange={(e) =>
                                                        wijzig(index, "patroon", e.target.value)
                                                    }
                                                    aria-label="Regexpatroon"
                                                />
                                            </td>
                                            <td>
                                                <input
                                                    type="text"
                                                    className="mono"
                                                    value={regel.entiteit}
                                                    placeholder="RANG"
                                                    onChange={(e) =>
                                                        wijzig(index, "entiteit", e.target.value)
                                                    }
                                                    aria-label="Entiteit"
                                                    title="Verschijnt in de hover van het lakvlak, bv. 'AI RANG'"
                                                />
                                            </td>
                                            <td>
                                                <input
                                                    type="text"
                                                    list="custom-entity-gronden"
                                                    value={regel.grond}
                                                    placeholder="5.1.2.e"
                                                    onChange={(e) =>
                                                        wijzig(index, "grond", e.target.value)
                                                    }
                                                    aria-label="Weigeringsgrond"
                                                    title="Verschijnt als label op het lakvlak, bv. '5.1.2.e'"
                                                />
                                            </td>
                                            <td>
                                                <input
                                                    type="checkbox"
                                                    checked={Boolean(regel.case_sensitief)}
                                                    onChange={(e) =>
                                                        wijzig(
                                                            index,
                                                            "case_sensitief",
                                                            e.target.checked
                                                        )
                                                    }
                                                    aria-label="Case-sensitief"
                                                    title="Case-sensitief"
                                                />
                                            </td>
                                            <td>
                                                <button
                                                    className="klein secundair"
                                                    onClick={() =>
                                                        setRegels((v) =>
                                                            v.filter((_, i) => i !== index)
                                                        )
                                                    }
                                                    aria-label={`${regel.naam || "regel"} verwijderen`}
                                                >
                                                    ×
                                                </button>
                                            </td>
                                        </tr>
                                        {(fout || (testTekst && gevonden.length > 0)) && (
                                            <tr>
                                                <td />
                                                <td colSpan={6}>
                                                    {fout && (
                                                        <span className={styles.foutregel}>
                                                            {fout}
                                                        </span>
                                                    )}
                                                    {!fout && gevonden.length > 0 && (
                                                        <span className={styles.treffers}>
                                                            {gevonden.length} treffer(s):{" "}
                                                            <span className="mono">
                                                                {gevonden.join(" · ")}
                                                            </span>
                                                        </span>
                                                    )}
                                                </td>
                                            </tr>
                                        )}
                                    </React.Fragment>
                                );
                            })}
                        </tbody>
                    </table>
                )}

                <datalist id="custom-entity-gronden">
                    {gronden.map((g) => (
                        <option key={g.code} value={g.code} />
                    ))}
                </datalist>

                <label className={styles.test}>
                    Proeftekst — controleer waar de patronen op aanslaan voordat je opslaat
                    <textarea
                        rows={3}
                        value={testTekst}
                        placeholder="Plak hier een stuk documenttekst…"
                        onChange={(e) => setTestTekst(e.target.value)}
                    />
                </label>
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/ExportKolommen/ExportKolommen.jsx =====
META: lines=249 bytes=10533
IMPORTS: ../../../api.js, ./ExportKolommen.module.css, react
EXPORTS: ExportKolommen
SYMBOLS: const-fn:bewaar, const-fn:hernummer, const-fn:verplaats, const-fn:verwijder, const-fn:voegToe, const-fn:wijzig
----- CODE START -----
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../../../api.js";
import styles from "./ExportKolommen.module.css";

const REPORT_TYPE = "EXTERN";

const LEGE_KOLOM = {
    id: null,
    property_name: "",
    display_name: "",
    enabled: true,
    report_type: REPORT_TYPE,
    sort_order: -1,
    column_order: 0,
};

export default function ExportKolommen({ toon }) {
    const [kolommen, setKolommen] = useState([]);
    const [velden, setVelden] = useState([]);
    const [bezig, setBezig] = useState(false);
    const [geladen, setGeladen] = useState(false);

    const laad = useCallback(() => {
        api.beheer
            .exportKolommen()
            .then((data) => {
                setKolommen(
                    (data?.kolommen || []).map((k) => ({
                        ...LEGE_KOLOM,
                        ...k,
                        enabled: true,
                        report_type: REPORT_TYPE,
                        sort_order: Number(k.sort_order ?? -1),
                        column_order: Number(k.column_order ?? 0),
                    }))
                );
                setVelden(data?.beschikbareVelden || []);
                setGeladen(true);
            })
            .catch((e) => {
                toon(`Exportkolommen ophalen mislukt: ${e.message}`);
                setGeladen(true);
            });
    }, [toon]);

    useEffect(() => laad(), [laad]);

    // Indexen in de volledige lijst, zodat wijzigen werkt terwijl we sorteren.
    const zichtbaar = useMemo(
        () =>
            kolommen
                .map((kolom, index) => ({ kolom, index }))
                .sort((a, b) => a.kolom.column_order - b.kolom.column_order),
        [kolommen]
    );

    const wijzig = (index, veld, waarde) =>
        setKolommen((vorige) =>
            vorige.map((kolom, i) => (i === index ? { ...kolom, [veld]: waarde } : kolom))
        );

    /** Verplaatst een kolom en hernummert column_order binnen dit overzicht. */
    const verplaats = (positie, richting) => {
        const doel = positie + richting;
        if (doel < 0 || doel >= zichtbaar.length) return;

        const volgorde = zichtbaar.map((item) => item.index);
        [volgorde[positie], volgorde[doel]] = [volgorde[doel], volgorde[positie]];

        setKolommen((vorige) => {
            const kopie = [...vorige];
            volgorde.forEach((index, nieuweVolgorde) => {
                kopie[index] = { ...kopie[index], column_order: nieuweVolgorde };
            });
            return kopie;
        });
    };

    const hernummer = () =>
        setKolommen((vorige) => {
            const kopie = [...vorige];
            zichtbaar.forEach(({ index }, positie) => {
                kopie[index] = { ...kopie[index], column_order: positie };
            });
            return kopie;
        });

    const voegToe = () =>
        setKolommen((vorige) => [
            ...vorige,
            { ...LEGE_KOLOM, column_order: zichtbaar.length },
        ]);

    const verwijder = (index) => setKolommen((vorige) => vorige.filter((_, i) => i !== index));

    const fout = useMemo(() => {
        const gezien = new Set();
        for (const kolom of kolommen) {
            if (!kolom.property_name.trim()) return "Elke regel heeft een veldnaam nodig";
            if (!kolom.display_name.trim()) {
                return `'${kolom.property_name}': weergavenaam is verplicht`;
            }
            const sleutel = `${REPORT_TYPE}:${kolom.property_name.toLowerCase()}`;
            if (gezien.has(sleutel)) {
                return `'${kolom.property_name}' komt twee keer voor in hetzelfde overzicht`;
            }
            gezien.add(sleutel);
        }
        return null;
    }, [kolommen]);

    const bewaar = async () => {
        if (fout || bezig) return;
        setBezig(true);
        try {
            const data = await api.beheer.bewaarExportKolommen(
                kolommen.map((kolom) => ({
                    ...kolom,
                    enabled: true,
                    report_type: REPORT_TYPE,
                }))
            );
            toon(`${data.aantal} kolom(men) opgeslagen.`);
            laad();
        } catch (e) {
            toon(e.message);
        } finally {
            setBezig(false);
        }
    };

    return (
        <div className="kaart">
            <div className="werkbalk">
                <strong>Exportkolommen</strong>
                <div className="rek" />
                <button className="klein secundair" onClick={hernummer}>
                    Volgorde opschonen
                </button>
                <button className="klein secundair" onClick={voegToe}>
                    Kolom toevoegen
                </button>
                <button className="klein" onClick={bewaar} disabled={Boolean(fout) || bezig}>
                    {bezig ? "Bezig…" : "Opslaan"}
                </button>
            </div>

            <div className={styles.body}>
                <p className={styles.uitleg}>
                    Bepaalt welke velden in het inventarisatieoverzicht komen en in welke volgorde.
                    De pijlen bepalen de kolomvolgorde. <strong>Sortering</strong> is de volgorde
                    waarop de regels gesorteerd worden: <span className="mono">-1</span> betekent
                    niet sorteren, <span className="mono">0</span> eerst, daarna 1, 2 enzovoort.
                </p>

                {fout && <p className={styles.fout}>{fout}</p>}
                {!geladen && <p className={styles.leeg}>Laden…</p>}
                {geladen && !zichtbaar.length && <p className={styles.leeg}>Nog geen kolommen.</p>}

                {zichtbaar.length > 0 && (
                    <table className={styles.tabel}>
                        <thead>
                            <tr>
                                <th>Volgorde</th>
                                <th>Veld</th>
                                <th>Weergavenaam</th>
                                <th>Sortering</th>
                                <th />
                            </tr>
                        </thead>
                        <tbody>
                            {zichtbaar.map(({ kolom, index }, positie) => (
                                <tr key={kolom.id || `nieuw-${index}`}>
                                    <td className={styles.pijlen}>
                                        <button
                                            className="klein secundair"
                                            onClick={() => verplaats(positie, -1)}
                                            disabled={positie === 0}
                                            aria-label="Naar boven"
                                        >
                                            ↑
                                        </button>
                                        <button
                                            className="klein secundair"
                                            onClick={() => verplaats(positie, 1)}
                                            disabled={positie === zichtbaar.length - 1}
                                            aria-label="Naar beneden"
                                        >
                                            ↓
                                        </button>
                                    </td>
                                    <td>
                                        <input
                                            type="text"
                                            className="mono"
                                            list="export-velden"
                                            value={kolom.property_name}
                                            onChange={(e) =>
                                                wijzig(index, "property_name", e.target.value)
                                            }
                                            aria-label="Veldnaam"
                                        />
                                    </td>
                                    <td>
                                        <input
                                            type="text"
                                            value={kolom.display_name}
                                            onChange={(e) =>
                                                wijzig(index, "display_name", e.target.value)
                                            }
                                            aria-label="Weergavenaam"
                                        />
                                    </td>
                                    <td>
                                        <input
                                            type="number"
                                            className={styles.getal}
                                            value={kolom.sort_order}
                                            onChange={(e) =>
                                                wijzig(index, "sort_order", Number(e.target.value))
                                            }
                                            aria-label="Sorteervolgorde"
                                        />
                                    </td>
                                    <td>
                                        <button
                                            className="klein secundair"
                                            onClick={() => verwijder(index)}
                                            aria-label={`${kolom.display_name || "kolom"} verwijderen`}
                                        >
                                            ×
                                        </button>
                                    </td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                )}

                <datalist id="export-velden">
                    {velden.map((veld) => (
                        <option key={veld} value={veld} />
                    ))}
                </datalist>
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/Gronden/Gronden.jsx =====
META: lines=145 bytes=5758
IMPORTS: ../../../api.js, ./Gronden.module.css, react
EXPORTS: Gronden
SYMBOLS: const-fn:bewaar, const-fn:summaryVoor, const-fn:verwijder, const-fn:voegToe, const-fn:zet, const-fn:zetSummary
----- CODE START -----
import React, { useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./Gronden.module.css";
export default function Gronden({ toon }) {
    const [gronden, setGronden] = useState(null);
    const [summaries, setSummaries] = useState([]);
    const [vuil, setVuil] = useState(false);

    useEffect(() => {
        api.beheer
            .weigeringsgronden()
            .then((d) => {
                setGronden(d.gronden);
                setSummaries(d.summaries);
            })
            .catch((e) => toon(e.message));
    }, [toon]);

    const zet = (i, veld, waarde) => {
        setGronden(gronden.map((g, idx) => (idx === i ? { ...g, [veld]: waarde } : g)));
        setVuil(true);
    };

    const summaryVoor = (code) => summaries.find((s) => s.codeOrGroup === code)?.summary || "";

    const zetSummary = (code, tekst) => {
        const bestaand = summaries.find((s) => s.codeOrGroup === code);
        setSummaries(
            bestaand
                ? summaries.map((s) => (s.codeOrGroup === code ? { ...s, summary: tekst } : s))
                : [...summaries, { id: String(Date.now()), codeOrGroup: code, summary: tekst }]
        );
        setVuil(true);
    };

    const voegToe = () => {
        setGronden([
            ...gronden,
            { id: String(Date.now()), articleNumber: "", description: "", articleGroup: "" },
        ]);
        setVuil(true);
    };

    const verwijder = (i) => {
        setGronden(gronden.filter((_, idx) => idx !== i));
        setVuil(true);
    };

    const bewaar = async () => {
        const schoon = gronden.filter((g) => g.articleNumber.trim());
        try {
            await api.beheer.bewaarWeigeringsgronden(
                schoon,
                summaries.filter((s) => s.summary?.trim())
            );
            setVuil(false);
            toon(
                "Weigeringsgronden opgeslagen. Denk eraan: reeds definitief gelakte documenten behouden hun oorspronkelijke grondtekst."
            );
        } catch (e) {
            toon(e.message);
        }
    };

    if (!gronden) return <div className="leeg">Laden…</div>;

    return (
        <div className={`${styles.cardShell} ${styles.card}`}>
            <div className={styles.toolbar}>
                <span className={styles.hint}>
                    De samenvatting wordt de overlaytekst op de lak.
                </span>
                <div className={styles.spacer} />
                <button className={styles.smallSecondary} onClick={voegToe}>
                    Grond toevoegen
                </button>
                <button className={styles.smallPrimary} disabled={!vuil} onClick={bewaar}>
                    Opslaan
                </button>
            </div>
            <table className={styles.table}>
                <thead>
                    <tr>
                        <th className={styles.codeCol}>Code</th>
                        <th>Omschrijving</th>
                        <th className={styles.groupCol}>Groep</th>
                        <th>Samenvatting (overlaytekst)</th>
                        <th className={styles.actionCol} />
                    </tr>
                </thead>
                <tbody>
                    {gronden.map((g, i) => (
                        <tr key={g.id ?? i}>
                            <td>
                                <input
                                    type="text"
                                    className={`${styles.mono} ${styles.fullInput}`}
                                    value={g.articleNumber}
                                    onChange={(e) => zet(i, "articleNumber", e.target.value)}
                                    aria-label="Grondcode"
                                />
                            </td>
                            <td>
                                <input
                                    type="text"
                                    className={styles.fullInput}
                                    value={g.description || ""}
                                    onChange={(e) => zet(i, "description", e.target.value)}
                                    aria-label="Omschrijving"
                                />
                            </td>
                            <td>
                                <input
                                    type="text"
                                    className={`${styles.mono} ${styles.fullInput}`}
                                    value={g.articleGroup || ""}
                                    onChange={(e) => zet(i, "articleGroup", e.target.value)}
                                    aria-label="Groep"
                                />
                            </td>
                            <td>
                                <input
                                    type="text"
                                    className={styles.fullInput}
                                    value={summaryVoor(g.articleNumber)}
                                    onChange={(e) => zetSummary(g.articleNumber, e.target.value)}
                                    aria-label="Samenvatting"
                                />
                            </td>
                            <td>
                                <button
                                    className={styles.smallSecondary}
                                    onClick={() => verwijder(i)}
                                >
                                    Verwijderen
                                </button>
                            </td>
                        </tr>
                    ))}
                </tbody>
            </table>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/Lijsten/Lijsten.jsx =====
META: lines=130 bytes=4798
IMPORTS: ../../../api.js, ./Lijsten.module.css, react
EXPORTS: Lijsten
SYMBOLS: const-fn:actie, fn:Lijst
----- CODE START -----
import React, { useCallback, useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./Lijsten.module.css";

function Lijst({ type, titel, uitleg, toon }) {
    const [items, setItems] = useState([]);
    const [invoer, setInvoer] = useState("");
    const [gronden, setGronden] = useState([]);
    const [grondKeuze, setGrondKeuze] = useState("");
    const isBlacklist = type === "blacklist";

    const laad = useCallback(() => {
        api.beheer
            .lijst(type)
            .then(setItems)
            .catch(() => setItems([]));
    }, [type]);

    useEffect(() => {
        laad();
    }, [laad]);
    useEffect(() => {
        if (isBlacklist)
            api.weigeringsgronden()
                .then(setGronden)
                .catch(() => setGronden([]));
    }, [isBlacklist]);

    const actie = async (welke, text) => {
        if (welke === "add" && isBlacklist && !grondKeuze) {
            toon("Kies eerst een weigeringsgrond voor deze blacklist-term.");
            return;
        }
        try {
            await api.beheer.lijstActie(type, welke, text, isBlacklist ? grondKeuze : undefined);
            setInvoer("");
            setGrondKeuze("");
            laad();
        } catch (e) {
            toon(e.message);
        }
    };

    return (
        <div className="kaart">
            <div className="werkbalk">
                <strong>{titel}</strong>
                <div className="rek" />
                <span className="taginvoer">
                    <input
                        type="text"
                        placeholder="Nieuwe term…"
                        value={invoer}
                        onChange={(e) => setInvoer(e.target.value)}
                        onKeyDown={(e) => {
                            if (e.key === "Enter" && invoer.trim() && (!isBlacklist || grondKeuze))
                                actie("add", invoer);
                        }}
                        aria-label={`Nieuwe ${type}-term`}
                    />
                    {isBlacklist && (
                        <select
                            value={grondKeuze}
                            onChange={(e) => setGrondKeuze(e.target.value)}
                            aria-label="Weigeringsgrond voor deze term"
                            className={styles.groundSelect}
                        >
                            <option value="">Kies grond…</option>
                            {gronden.map((g) => (
                                <option key={g.code} value={g.code}>
                                    {g.code}
                                </option>
                            ))}
                        </select>
                    )}
                    <button
                        className="klein secundair"
                        disabled={!invoer.trim() || (isBlacklist && !grondKeuze)}
                        onClick={() => actie("add", invoer)}
                    >
                        Toevoegen
                    </button>
                </span>
            </div>
            <div className={styles.body}>
                <p className={styles.uitleg}>{uitleg}</p>
                <span className="tagchips">
                    {items.map((i) => (
                        <span key={i.text} className="tagchip">
                            {i.text}
                            {isBlacklist && i.refusalGround && (
                                <span className={`mono ${styles.refusalGround}`}>
                                    {" "}
                                    · {i.refusalGround}
                                </span>
                            )}
                            <button
                                aria-label={`${i.text} verwijderen`}
                                onClick={() => actie("remove", i.text)}
                            >
                                ×
                            </button>
                        </span>
                    ))}
                    {items.length === 0 && <span className={styles.empty}>Nog geen termen.</span>}
                </span>
            </div>
        </div>
    );
}

export default function Lijsten({ toon }) {
    return (
        <div className={`kaartrij ${styles.gridTwo}`}>
            <Lijst
                type="whitelist"
                titel="Whitelist"
                uitleg="Termen die nooit gelakt worden, ook niet door AI- of blacklistsuggesties."
                toon={toon}
            />
            <Lijst
                type="blacklist"
                titel="Blacklist"
                uitleg="Termen die overal automatisch een laksuggestie krijgen."
                toon={toon}
            />
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/MaakAccount/MaakAccount.jsx =====
META: lines=130 bytes=5366
IMPORTS: ../../../api.js, ./MaakAccount.module.css, react
EXPORTS: MaakAccount
SYMBOLS: const-fn:kopieer, const-fn:maak, const-fn:toggleRol
----- CODE START -----
import React, { useState } from "react";
import { api } from "../../../api.js";
import styles from "./MaakAccount.module.css";

const ROL_OPTIES = ["behandelaar", "beoordelaar", "beheerder"];

export default function MaakAccount({ toon }) {
    const [naam, setNaam] = useState("");
    const [gebruikersnaam, setGebruikersnaam] = useState("");
    const [wachtwoord, setWachtwoord] = useState("");
    const [wachtwoordHerhaal, setWachtwoordHerhaal] = useState("");
    const [rollen, setRollen] = useState(["behandelaar"]);
    const [resultaat, setResultaat] = useState(null);

    const gebruikersnaamOk = /^[\w.\-]{1,80}$/.test(gebruikersnaam.trim());
    const wachtwoordenOk = wachtwoord.length >= 8 && wachtwoord === wachtwoordHerhaal;
    const kanMaken = gebruikersnaamOk && wachtwoordenOk && rollen.length > 0;

    const toggleRol = (rol) =>
        setRollen((prev) => (prev.includes(rol) ? prev.filter((r) => r !== rol) : [...prev, rol]));

    const maak = async () => {
        try {
            const account = await api.beheer.maakAccountVoorbeeld({
                naam: naam.trim(),
                gebruikersnaam: gebruikersnaam.trim(),
                wachtwoord,
                rollen,
            });
            setResultaat(account);
            toon("Account-JSON gegenereerd. Niets is opgeslagen op de server.");
        } catch (e) {
            toon(e.message);
        }
    };

    const kopieer = async () => {
        if (!resultaat) return;
        await navigator.clipboard.writeText(JSON.stringify(resultaat, null, 4));
        toon("JSON gekopieerd naar klembord.");
    };

    return (
        <div className={`kaart ${styles.card}`}>
            <div className="werkbalk">
                <strong>Nieuw account (voor OpenShift secret)</strong>
                <div className="rek" />
                <button className="klein" disabled={!kanMaken} onClick={maak}>
                    Genereer JSON
                </button>
                <button className="klein secundair" disabled={!resultaat} onClick={kopieer}>
                    Kopieer JSON
                </button>
            </div>
            <div className={styles.formGrid}>
                <label className={styles.fieldLabel}>
                    Naam
                    <input
                        type="text"
                        value={naam}
                        onChange={(e) => setNaam(e.target.value)}
                        placeholder="Bijv. P. Tester"
                    />
                </label>
                <label className={styles.fieldLabel}>
                    Gebruikersnaam
                    <input
                        type="text"
                        className="mono"
                        value={gebruikersnaam}
                        onChange={(e) => setGebruikersnaam(e.target.value)}
                        placeholder="bijv. p.tester"
                    />
                </label>
                <label className={styles.fieldLabel}>
                    Wachtwoord
                    <input
                        type="password"
                        value={wachtwoord}
                        onChange={(e) => setWachtwoord(e.target.value)}
                    />
                </label>
                <label className={styles.fieldLabel}>
                    Wachtwoord (herhaal)
                    <input
                        type="password"
                        value={wachtwoordHerhaal}
                        onChange={(e) => setWachtwoordHerhaal(e.target.value)}
                    />
                </label>
                <div>
                    <div className={styles.rolesHeader}>Rollen</div>
                    <div className={styles.rolesRow}>
                        {ROL_OPTIES.map((rol) => (
                            <label key={rol} className={styles.roleOption}>
                                <input
                                    type="checkbox"
                                    checked={rollen.includes(rol)}
                                    onChange={() => toggleRol(rol)}
                                />
                                <span className="mono">{rol}</span>
                            </label>
                        ))}
                    </div>
                </div>
                {!gebruikersnaamOk && gebruikersnaam.length > 0 && (
                    <div className={`leeg ${styles.warning}`}>
                        Gebruikersnaam: 1-80 tekens, alleen letters/cijfers/._-
                    </div>
                )}
                {!wachtwoordenOk && (wachtwoord.length > 0 || wachtwoordHerhaal.length > 0) && (
                    <div className={`leeg ${styles.warning}`}>
                        Wachtwoord moet minimaal 8 tekens zijn en beide velden moeten gelijk zijn.
                    </div>
                )}
                {resultaat && (
                    <div>
                        <div className={styles.resultHeader}>
                            Kopieer dit blok naar je accounts-secret
                        </div>
                        <pre className={`mono ${styles.codeBlock}`}>
                            <code>{JSON.stringify(resultaat, null, 4)}</code>
                        </pre>
                    </div>
                )}
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/TonicEntities/TonicEntities.jsx =====
META: lines=151 bytes=6092
IMPORTS: ../../../api.js, ./TonicEntities.module.css, react
EXPORTS: TonicEntities
SYMBOLS: const-fn:bewaar, const-fn:toggleEntity, const-fn:wijzigRule
----- CODE START -----
import React, { useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./TonicEntities.module.css";

export default function TonicEntities({ toon }) {
    const [apiEntities, setApiEntities] = useState([]);
    const [selectedEntities, setSelectedEntities] = useState([]);
    const [mappings, setMappings] = useState([]);
    const [gronden, setGronden] = useState([]);
    const [laden, setLaden] = useState(true);
    const [vuil, setVuil] = useState(false);

    useEffect(() => {
        Promise.all([
            api.beheer.tonicApiEntities(),
            api.beheer.tonicEntities(),
            api.beheer.tonicEntityMappings(),
            api.weigeringsgronden(),
        ])
            .then(([apiList, selectedList, mappingList, refusalGrounds]) => {
                setApiEntities(apiList || []);
                setSelectedEntities(selectedList || []);
                setMappings(mappingList || []);
                setGronden(refusalGrounds || []);
            })
            .catch((e) => toon(e.message))
            .finally(() => setLaden(false));
    }, [toon]);

    const selectedByEntity = new Map((selectedEntities || []).map((item) => [item.entity, item]));
    const mappingByLabel = new Map((mappings || []).map((item) => [item.label, item]));

    const rows = apiEntities.map((entity) => ({
        entity,
        selected: selectedByEntity.has(entity),
        rule: mappingByLabel.get(entity)?.rule || "",
    }));

    const toggleEntity = (entity, checked) => {
        if (checked && !selectedByEntity.has(entity)) {
            setSelectedEntities((prev) => [...prev, { entity }]);
        }
        if (!checked && selectedByEntity.has(entity)) {
            setSelectedEntities((prev) => prev.filter((item) => item.entity !== entity));
            setMappings((prev) => prev.filter((item) => item.label !== entity));
        }
        setVuil(true);
    };

    const wijzigRule = (entity, rule) => {
        const bestaand = mappingByLabel.get(entity);
        if (bestaand) {
            setMappings((prev) =>
                prev.map((item) =>
                    item.label === entity ? { ...item, rule, annotation: entity } : item
                )
            );
        } else {
            setMappings((prev) => [...prev, { label: entity, annotation: entity, rule }]);
        }
        setVuil(true);
    };

    const bewaar = async () => {
        const entitiesPayload = selectedEntities.map((item) => ({
            ...(item.id ? { id: item.id } : {}),
            entity: item.entity,
        }));
        const mappingsPayload = mappings
            .filter((item) => selectedByEntity.has(item.label) && item.rule)
            .map((item) => ({
                ...(item.id ? { id: item.id } : {}),
                label: item.label,
                annotation: item.annotation || item.label,
                rule: item.rule,
            }));
        try {
            const savedEntities = await api.beheer.bewaarTonicEntities(entitiesPayload);
            const savedMappings = await api.beheer.bewaarTonicEntityMappings(mappingsPayload);
            setSelectedEntities(savedEntities || []);
            setMappings(savedMappings || []);
            setVuil(false);
            toon("Tonic AI-selectie en mapping opgeslagen.");
        } catch (e) {
            toon(e.message);
        }
    };

    if (laden) return <div className="leeg">Laden…</div>;

    return (
        <div className={`kaart ${styles.card}`}>
            <div className="werkbalk">
                <span className={styles.hint}>
                    Selecteer Tonic AI-entiteiten en koppel ze aan een weigeringsgrond.
                </span>
                <div className="rek" />
                <button className="klein" disabled={!vuil} onClick={bewaar}>
                    Opslaan
                </button>
            </div>
            <table className="gegevens">
                <thead>
                    <tr>
                        <th className={styles.enabledCol}>Aan</th>
                        <th>Entity</th>
                        <th className={styles.ruleCol}>Weigeringsgrond</th>
                    </tr>
                </thead>
                <tbody>
                    {rows.map((row) => (
                        <tr key={row.entity}>
                            <td>
                                <input
                                    type="checkbox"
                                    checked={row.selected}
                                    onChange={(e) => toggleEntity(row.entity, e.target.checked)}
                                    aria-label={`Selecteer ${row.entity}`}
                                />
                            </td>
                            <td className="mono">{row.entity}</td>
                            <td>
                                <select
                                    value={row.rule}
                                    disabled={!row.selected}
                                    onChange={(e) => wijzigRule(row.entity, e.target.value)}
                                    aria-label={`Weigeringsgrond voor ${row.entity}`}
                                >
                                    <option value="">Kies grond…</option>
                                    {gronden.map((g) => (
                                        <option key={g.code} value={g.code}>
                                            {g.code} — {g.description}
                                        </option>
                                    ))}
                                </select>
                            </td>
                        </tr>
                    ))}
                    {rows.length === 0 && (
                        <tr>
                            <td colSpan="3" className="leeg">
                                Geen Tonic AI-entiteiten gevonden.
                            </td>
                        </tr>
                    )}
                </tbody>
            </table>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beheer/Zaken/Zaken.jsx =====
META: lines=177 bytes=6638
IMPORTS: ../../../api.js, ./Zaken.module.css, react
EXPORTS: Zaken
SYMBOLS: const-fn:bewaar, const-fn:pauzeerHervat, const-fn:verwijderZaak, const-fn:wisselFase, const-fn:zet, fn:ZaakConfig
----- CODE START -----
import React, { useCallback, useEffect, useState } from "react";
import { api } from "../../../api.js";
import styles from "./Zaken.module.css";

const FASEN_UIT_OPTIES = [
    { id: "ai", label: "AI-suggesties overslaan" },
    { id: "exporteren", label: "Exportfase overslaan" },
];

function ZaakConfig({ zaak, toon, herlaad }) {
    const [config, setConfig] = useState({
        max_attempts: 3,
        fasen_uit: [],
        ...(zaak.config || {}),
    });
    const [vuil, setVuil] = useState(false);
    const [bezigMetVerwijderen, setBezigMetVerwijderen] = useState(false);

    const verwijderZaak = async () => {
        setBezigMetVerwijderen(true);
        try {
            const controle = await api.verwijderZaakControle(zaak.case_id);
            const inv = controle?.inventaris || {};

            const bevestigd = window.confirm(
                `Zaak "${zaak.case_id}" volledig verwijderen?\n\n` +
                    `Documenten: ${Number(inv.documenten || 0).toLocaleString("nl-NL")}\n` +
                    `Versies: ${Number(inv.versies || 0).toLocaleString("nl-NL")}\n` +
                    `Fasen: ${Number(inv.document_fasen || 0).toLocaleString("nl-NL")}, ` +
                    `batches: ${Number(inv.batches || 0).toLocaleString("nl-NL")}\n` +
                    `Werkkopieën: ${Number(inv.werkkopieen || 0).toLocaleString("nl-NL")}, ` +
                    `snapshots: ${Number(inv.snapshots || 0).toLocaleString("nl-NL")}\n\n` +
                    `Inclusief alle bestanden op de PVC. Dit kan niet ongedaan worden gemaakt.`
            );
            if (!bevestigd) return;

            const getypt = window.prompt(`Typ de zaaknaam ter bevestiging:`, "");
            if (getypt !== zaak.case_id) {
                if (getypt !== null) toon("Zaaknaam kwam niet overeen; er is niets verwijderd.");
                return;
            }

            await api.verwijderZaak(zaak.case_id);
            toon(`Zaak "${zaak.case_id}" verwijderd.`);
            herlaad();
        } catch (e) {
            toon(`Verwijderen mislukt: ${e.message}`);
        } finally {
            setBezigMetVerwijderen(false);
        }
    };

    const zet = (veld, waarde) => {
        setConfig({ ...config, [veld]: waarde });
        setVuil(true);
    };

    const wisselFase = (fase) => {
        const set = new Set(config.fasen_uit || []);
        set.has(fase) ? set.delete(fase) : set.add(fase);
        zet("fasen_uit", [...set]);
    };

    const bewaar = async () => {
        try {
            const payload = { ...config };
            delete payload.batch_size;
            delete payload.max_open_batches;
            await api.beheer.bewaarZaakConfig(zaak.case_id, payload);
            setConfig(payload);
            setVuil(false);
            toon(`Instellingen van ${zaak.case_id} opgeslagen.`);
        } catch (e) {
            toon(e.message);
        }
    };

    const pauzeerHervat = async () => {
        try {
            const actie = zaak.gepauzeerd ? "hervat" : "pauzeer";
            const data = await api.beheer.zaakActie(zaak.case_id, actie);

            if (actie === "hervat") {
                const n = Number(data?.wacht_op_verwerking || 0);
                toon(
                    `Verwerking gestart. ${n.toLocaleString("nl-NL")} document(en) toegevoegd aan de wachtrij.`
                );
            } else {
                toon("Zaak gepauzeerd.");
            }

            herlaad();
        } catch (e) {
            toon(e.message);
        }
    };

    return (
        <div className={`kaart ${styles.card}`}>
            <div className={styles.headerRow}>
                <strong className="mono">{zaak.case_id}</strong>
                <span
                    className={`status ${zaak.status === "AFGEROND" ? "geexporteerd" : "in_beoordeling"}`}
                >
                    {zaak.status}
                </span>
                {zaak.gepauzeerd && <span className="status ingelezen">gepauzeerd</span>}
                <div className={`rek ${styles.headerSpacer}`} />
                <button className="klein secundair" onClick={pauzeerHervat}>
                    {zaak.gepauzeerd ? "Verwerking starten" : "Pauzeren"}
                </button>
                <button className="klein" disabled={!vuil} onClick={bewaar}>
                    Opslaan
                </button>
                <button
                    className="klein secundair"
                    disabled={bezigMetVerwijderen}
                    onClick={verwijderZaak}
                    title="Verwijdert de zaak inclusief documenten, fasen en bestanden op de PVC"
                >
                    {bezigMetVerwijderen ? "Bezig…" : "Zaak verwijderen"}
                </button>
            </div>
            <div className={styles.formRow}>
                <label>
                    Max. pogingen{" "}
                    <input
                        type="number"
                        min="1"
                        max="10"
                        className={styles.numberInput}
                        value={config.max_attempts}
                        onChange={(e) => zet("max_attempts", Number(e.target.value))}
                    />
                </label>
                {FASEN_UIT_OPTIES.map((o) => (
                    <label key={o.id} className={styles.optionLabel}>
                        <input
                            type="checkbox"
                            checked={(config.fasen_uit || []).includes(o.id)}
                            onChange={() => wisselFase(o.id)}
                        />
                        {o.label}
                    </label>
                ))}
            </div>
        </div>
    );
}

export default function Zaken({ toon }) {
    const [zaken, setZaken] = useState(null);
    const laad = useCallback(() => {
        api.beheer
            .zaken()
            .then(setZaken)
            .catch((e) => toon(e.message));
    }, [toon]);

    useEffect(() => {
        laad();
    }, [laad]);

    if (!zaken) return <div className="leeg">Laden…</div>;

    return (
        <div className="kaartrij">
            {zaken.map((z) => (
                <ZaakConfig key={z.case_id} zaak={z} toon={toon} herlaad={laad} />
            ))}
            {zaken.length === 0 && (
                <div className="leeg">Nog geen zaken bij de orchestrator aangemeld.</div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/RechterpaneelGronden/RechterpaneelGronden.jsx =====
META: lines=29 bytes=1147
IMPORTS: ./RechterpaneelGronden.module.css, react
EXPORTS: RechterpaneelGronden
----- CODE START -----
import React from "react";
import styles from "./RechterpaneelGronden.module.css";

export default function RechterpaneelGronden({ gronden, actieveGrond, apryseActief, onKiesGrond }) {
    return (
        <section className={styles.section}>
            <h3>Weigeringsgronden</h3>
            {!apryseActief && (
                <p className={styles.hint}>
                    Alleen beschikbaar in de Apryse-fasen (geconverteerd / AI / beoordeling) en niet
                    in historische preview.
                </p>
            )}
            {gronden.map((g) => (
                <button
                    key={g.code}
                    className={`${styles.grond} ${actieveGrond === g.code ? styles.actief : ""}`}
                    onClick={() => onKiesGrond(g)}
                    disabled={!apryseActief}
                >
                    <span className={styles.code}>{g.code}</span>
                    <span className={styles.oms}>{g.description}</span>
                </button>
            ))}
            {gronden.length === 0 && <p className={styles.oms}>Geen gronden geconfigureerd.</p>}
        </section>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/RechterpaneelSnapshots/RechterpaneelSnapshots.jsx =====
META: lines=34 bytes=1404
IMPORTS: ./RechterpaneelSnapshots.module.css, react
EXPORTS: RechterpaneelSnapshots
----- CODE START -----
import React from "react";
import styles from "./RechterpaneelSnapshots.module.css";

export default function RechterpaneelSnapshots({ snapshots, apryseActief, onHerstel }) {
    return (
        <section className={styles.section}>
            <h3>Snapshots (lakversies)</h3>
            {!apryseActief && (
                <p className={styles.hint}>
                    Snapshot terugzetten is alleen beschikbaar in Apryse-modus.
                </p>
            )}
            <div className={styles.snapshotList}>
                {snapshots.length === 0 && <p className={styles.empty}>Nog geen snapshots.</p>}
                {snapshots.map((s) => (
                    <div className={styles.snapshot} key={s.snapshot_id}>
                        <div className={styles.author}>{s.snapshot_created_by}</div>
                        <div className={styles.timestamp}>
                            {new Date(s.snapshot_creation_date).toLocaleString("nl-NL")}
                        </div>
                        <button
                            className={styles.restoreButton}
                            onClick={() => onHerstel(s)}
                            disabled={!apryseActief}
                        >
                            Terugzetten als werkkopie
                        </button>
                    </div>
                ))}
            </div>
        </section>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/RechterpaneelTags/RechterpaneelTags.jsx =====
META: lines=45 bytes=1765
IMPORTS: ./RechterpaneelTags.module.css, react
EXPORTS: RechterpaneelTags
----- CODE START -----
import React from "react";
import styles from "./RechterpaneelTags.module.css";
export default function RechterpaneelTags({ docTags, tagInvoer, onTagInvoerChange, onWijzigTags }) {
    return (
        <section className={styles.section}>
            <h3>Tags</h3>
            <span className={styles.tagChips}>
                {docTags.map((t) => (
                    <span key={t} className={styles.tagChip}>
                        {t}
                        <button
                            className={styles.tagChipButton}
                            aria-label={`Tag ${t} verwijderen`}
                            onClick={() => onWijzigTags([], [t])}
                        >
                            ×
                        </button>
                    </span>
                ))}
                {docTags.length === 0 && <span className={styles.empty}>Nog geen tags.</span>}
            </span>
            <div className={styles.tagInputRow}>
                <input
                    type="text"
                    placeholder="Nieuwe tag…"
                    value={tagInvoer}
                    className={styles.tagInput}
                    onChange={(e) => onTagInvoerChange(e.target.value)}
                    onKeyDown={(e) => {
                        if (e.key === "Enter" && tagInvoer.trim()) onWijzigTags([tagInvoer], []);
                    }}
                    aria-label="Nieuwe tag"
                />
                <button
                    className={styles.smallSecondary}
                    disabled={!tagInvoer.trim()}
                    onClick={() => onWijzigTags([tagInvoer], [])}
                >
                    Toevoegen
                </button>
            </div>
        </section>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/Statistieken/Statistieken.jsx =====
META: lines=46 bytes=1703
IMPORTS: ./Statistieken.module.css, react
EXPORTS: Statistieken
----- CODE START -----
import React from "react";
import styles from "./Statistieken.module.css";

/**
 * Telling van de lakvlakken die op dit moment in de viewer staan.
 * Wordt niet getoond zolang er geen enkel lakvlak is.
 */
export default function Statistieken({ statistieken }) {
    if (!statistieken || !statistieken.totaal) return null;

    const { totaal, ai, handmatig, paginas, perGrond } = statistieken;

    return (
        <section className={styles.root}>
            <h3>Lakvlakken</h3>

            <div className={styles.kop}>
                <span className={styles.groot}>{totaal.toLocaleString("nl-NL")}</span>
                <span className={styles.zacht}>
                    op {paginas.toLocaleString("nl-NL")} pagina{paginas === 1 ? "" : "'s"}
                </span>
            </div>

            <div className={styles.herkomst}>
                <span title="Aangedragen door de automatische verwerking">
                    AI <strong>{ai.toLocaleString("nl-NL")}</strong>
                </span>
                <span title="Zelf toegevoegd of aangepast">
                    Handmatig <strong>{handmatig.toLocaleString("nl-NL")}</strong>
                </span>
            </div>

            <ul className={styles.gronden}>
                {perGrond.map(({ grond, aantal }) => (
                    <li key={grond}>
                        <span className={styles.grond} title={grond}>
                            {grond}
                        </span>
                        <span className={styles.aantal}>{aantal.toLocaleString("nl-NL")}</span>
                    </li>
                ))}
            </ul>
        </section>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/useBeoordelenData.js =====
META: lines=134 bytes=4230
IMPORTS: ../../api.js, react
EXPORTS: useBeoordelenData
SYMBOLS: const-fn:herlaadTags, const-fn:voegSnapshotToe, fn:useBeoordelenData
----- CODE START -----
import { useEffect, useState } from "react";
import { api } from "../../api.js";

export function useBeoordelenData(docId, caseId) {
    const [gronden, setGronden] = useState([]);
    const [snapshots, setSnapshots] = useState([]);
    const [docTags, setDocTags] = useState([]);
    const [currentUserName, setCurrentUserName] = useState("");

    const [versies, setVersies] = useState([]);
    const [huidigeVersieKey, setHuidigeVersieKey] = useState(null);
    const [werkkopieStatus, setWerkkopieStatus] = useState(null);

    const [viewerMeta, setViewerMeta] = useState({
        laden: true,
        mode: "native",
        fase: null,
        canAnnotate: false,
        naam: "",
        versie: null,
    });

    useEffect(() => {
        let actief = true;
        api.auth
            .me()
            .then((me) => {
                if (actief) setCurrentUserName(String(me?.naam || "").trim());
            })
            .catch(() => {
                if (actief) setCurrentUserName("");
            });
        return () => {
            actief = false;
        };
    }, []);

    useEffect(() => {
        let actief = true;
        fetch(`/api/documenten/${docId}/versies`)
            .then((res) => res.json().then((data) => ({ ok: res.ok, data })))
            .then(({ ok, data }) => {
                if (!actief) return;
                if (!ok) throw new Error(data?.message || "Fout");
                const items = Array.isArray(data?.items) ? data.items : [];
                const currentKey = data?.current_key || null;
                setVersies(items);
                setHuidigeVersieKey(currentKey);
                setWerkkopieStatus(data?.werkkopie || null);
            })
            .catch(() => {
                if (!actief) return;
                setVersies([]);
                setHuidigeVersieKey(null);
                setWerkkopieStatus(null);
            });
        return () => {
            actief = false;
        };
    }, [docId]);

    useEffect(() => {
        let actief = true;
        setViewerMeta({
            laden: true,
            mode: "native",
            fase: null,
            canAnnotate: false,
            naam: "",
            versie: null,
        });
        fetch(`/api/documenten/${docId}/viewer-mode`)
            .then((res) => res.json().then((data) => ({ ok: res.ok, data })))
            .then(({ ok, data }) => {
                if (!actief) return;
                if (!ok) throw new Error(data?.message || "Fout");
                setViewerMeta({
                    laden: false,
                    mode: data.mode || "native",
                    fase: data.fase || null,
                    canAnnotate: Boolean(data.canAnnotate),
                    naam: data.naam || "",
                    versie: data.versie ?? null,
                });
            })
            .catch(() => {
                if (!actief) return;
                setViewerMeta({
                    laden: false,
                    mode: "native",
                    fase: null,
                    canAnnotate: false,
                    naam: "",
                    versie: null,
                });
            });
        return () => {
            actief = false;
        };
    }, [docId]);

    useEffect(() => {
        api.weigeringsgronden()
            .then(setGronden)
            .catch(() => setGronden([]));
        api.snapshots(docId)
            .then(setSnapshots)
            .catch(() => {});
        api.documentTags(docId)
            .then((r) => setDocTags(r.tags || []))
            .catch(() => setDocTags([]));
    }, [docId]);

    const voegSnapshotToe = (snap) => setSnapshots((s) => [snap, ...s]);
    const herlaadTags = () =>
        api
            .documentTags(docId)
            .then((r) => setDocTags(r.tags || []))
            .catch(() => {});

    return {
        gronden,
        snapshots,
        docTags,
        currentUserName,
        versies,
        huidigeVersieKey,
        werkkopieStatus,
        viewerMeta,
        voegSnapshotToe,
        herlaadTags,
    };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/useDocumentBuren.js =====
META: lines=52 bytes=1805
IMPORTS: ../../api.js, react
EXPORTS: useDocumentBuren
SYMBOLS: fn:useDocumentBuren
----- CODE START -----
import { useCallback, useEffect, useState } from "react";
import { api } from "../../api.js";

const LEEG = { vorige: null, volgende: null, positie: null, totaal: 0 };

/**
 * Vorige/volgende document binnen de zaak, in dezelfde volgorde als de
 * documentenlijst. `lijstQuery` bevat de filters/sortering van die lijst
 * (q, fase, tag, sort, dir) zodat "volgende" is wat de gebruiker verwacht.
 */
export function useDocumentBuren(caseId, docId, lijstQuery = "") {
    const [buren, setBuren] = useState(LEEG);

    const laad = useCallback(() => {
        if (!caseId || !docId) return Promise.resolve();
        return api
            .documentBuren(caseId, docId, lijstQuery)
            .then((data) =>
                setBuren({
                    vorige: data?.vorige || null,
                    volgende: data?.volgende || null,
                    positie: data?.positie ?? null,
                    totaal: Number(data?.totaal || 0),
                })
            )
            .catch(() => setBuren(LEEG));
    }, [caseId, docId, lijstQuery]);

    useEffect(() => {
        let actief = true;
        setBuren(LEEG);
        api.documentBuren(caseId, docId, lijstQuery)
            .then((data) => {
                if (!actief) return;
                setBuren({
                    vorige: data?.vorige || null,
                    volgende: data?.volgende || null,
                    positie: data?.positie ?? null,
                    totaal: Number(data?.totaal || 0),
                });
            })
            .catch(() => {
                if (actief) setBuren(LEEG);
            });
        return () => {
            actief = false;
        };
    }, [caseId, docId, lijstQuery]);

    return { ...buren, herlaad: laad };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/useDocumentSneltoetsen.js =====
META: lines=40 bytes=1664
IMPORTS: react
EXPORTS: useDocumentSneltoetsen
SYMBOLS: const-fn:handler, fn:isTypeveld, fn:useDocumentSneltoetsen
----- CODE START -----
import { useEffect } from "react";

function isTypeveld(target) {
    const tag = String(target?.tagName || "").toLowerCase();
    return tag === "input" || tag === "textarea" || tag === "select" || Boolean(target?.isContentEditable);
}

/**
 * Alt+← / Alt+→ voor vorige/volgende document.
 *
 * De WebViewer draait in een eigen iframe en slikt toetsaanslagen zolang de
 * focus daar staat; daarom binden we het handlertje ook op het iframe-document
 * (zelfde origin: /webviewer wordt door onze eigen nginx geserveerd).
 */
export function useDocumentSneltoetsen({ houderRef, actief, viewerKlaar, onVorige, onVolgende }) {
    useEffect(() => {
        if (!actief) return undefined;

        const handler = (e) => {
            if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
            if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
            if (isTypeveld(e.target)) return;
            e.preventDefault();
            if (e.key === "ArrowLeft") onVorige?.();
            else onVolgende?.();
        };

        const documenten = [document];
        try {
            const iframeDoc = houderRef?.current?.querySelector("iframe")?.contentDocument;
            if (iframeDoc && iframeDoc !== document) documenten.push(iframeDoc);
        } catch {
            // cross-origin iframe: dan werkt de sneltoets alleen buiten de viewer
        }

        documenten.forEach((doc) => doc.addEventListener("keydown", handler));
        return () => documenten.forEach((doc) => doc.removeEventListener("keydown", handler));
    }, [actief, viewerKlaar, houderRef, onVorige, onVolgende]);
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/useVerlatenGuard.js =====
META: lines=75 bytes=2424
IMPORTS: react, react-router-dom
EXPORTS: useVerlatenGuard
SYMBOLS: const-fn:handler, fn:useVerlatenGuard
----- CODE START -----
import { useCallback, useEffect, useState } from "react";
import { useBlocker } from "react-router-dom";

/**
 * Onderschept het weg navigeren zolang er onvastgelegde wijzigingen zijn en
 * laat de aanroeper kiezen: vastleggen in de werkkopie, negeren, of blijven.
 *
 * Vereist een data-router (zie src/main.jsx: createBrowserRouter).
 */
export function useVerlatenGuard({ actief, onVastleggen, onNegeren }) {
    const [bezig, setBezig] = useState(null);
    const [fout, setFout] = useState("");

    const shouldBlock = useCallback(
        ({ currentLocation, nextLocation }) =>
            Boolean(actief) &&
            (currentLocation.pathname !== nextLocation.pathname ||
                currentLocation.search !== nextLocation.search),
        [actief]
    );

    const blocker = useBlocker(shouldBlock);

    // Sluiten van het tabblad / verversen: alleen de browser-eigen waarschuwing
    // is hier mogelijk. De autosave heeft het werk dan al in de werkkopie staan.
    useEffect(() => {
        if (!actief) return undefined;
        const handler = (e) => {
            e.preventDefault();
            e.returnValue = "";
        };
        window.addEventListener("beforeunload", handler);
        return () => window.removeEventListener("beforeunload", handler);
    }, [actief]);

    // Valt de reden om te blokkeren tussentijds weg, dan alsnog doorlaten.
    useEffect(() => {
        if (blocker.state === "blocked" && !actief && !bezig) {
            blocker.proceed();
        }
    }, [blocker, actief, bezig]);

    const afhandelen = useCallback(
        async (keuze) => {
            if (blocker.state !== "blocked" || bezig) return;

            if (keuze === "annuleren") {
                setFout("");
                blocker.reset();
                return;
            }

            setBezig(keuze);
            setFout("");
            try {
                if (keuze === "vastleggen") await onVastleggen();
                else await onNegeren();
                blocker.proceed();
            } catch (e) {
                setFout(String(e?.message || e));
            } finally {
                setBezig(null);
            }
        },
        [blocker, bezig, onVastleggen, onNegeren]
    );

    return {
        open: blocker.state === "blocked",
        bezig,
        fout,
        afhandelen,
    };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/VerlatenPopup/VerlatenPopup.jsx =====
META: lines=56 bytes=2089
IMPORTS: ./VerlatenPopup.module.css
EXPORTS: VerlatenPopup
----- CODE START -----
import styles from "./VerlatenPopup.module.css";

export default function VerlatenPopup({ open, bezig, fout, onKeuze }) {
    if (!open) return null;

    const geblokkeerd = Boolean(bezig);

    return (
        <div
            role="dialog"
            aria-modal="true"
            aria-label="Pagina verlaten"
            className={styles.overlay}
        >
            <div className={styles.panel}>
                <h3 className={styles.titel}>Je verlaat het beoordeelscherm</h3>
                <p className={styles.tekst}>
                    Je hebt lakwerk aangepast. Wil je deze wijzigingen vastleggen in de werkkopie,
                    of moeten ze worden genegeerd? Bij negeren gaat de werkkopie terug naar de stand
                    van het moment dat je dit document opende.
                </p>

                {fout && (
                    <p className={styles.fout} role="alert">
                        {fout}
                    </p>
                )}

                <div className={styles.knoppen}>
                    <button
                        className={styles.secundair}
                        onClick={() => onKeuze("annuleren")}
                        disabled={geblokkeerd}
                    >
                        Op deze pagina blijven
                    </button>
                    <button
                        className={styles.gevaar}
                        onClick={() => onKeuze("negeren")}
                        disabled={geblokkeerd}
                    >
                        {bezig === "negeren" ? "Bezig…" : "Wijzigingen negeren"}
                    </button>
                    <button
                        className={styles.primair}
                        onClick={() => onKeuze("vastleggen")}
                        disabled={geblokkeerd}
                    >
                        {bezig === "vastleggen" ? "Bezig…" : "Vastleggen in werkkopie"}
                    </button>
                </div>
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/VersiesPopup/VersiesPopup.jsx =====
META: lines=153 bytes=6204
IMPORTS: ./VersiesPopup.module.css, react
EXPORTS: VersiesPopup
----- CODE START -----
import { useEffect } from "react";
import styles from "./VersiesPopup.module.css";

export default function VersiesPopup({
    open,
    onClose,
    versies,
    effectieveVersieKey,
    huidigeVersieKey,
    werkkopieVersieKey,
    onKiesVersie,
    onHerstelVersie,
    kanHerstelVersie,
    snapshots,
    apryseActief,
    onHerstelSnapshot,
}) {
    const debugVersions =
        window.__LAK_DEBUG_VERSIONS__ === true ||
        window.localStorage.getItem("lak.debug.versions") === "1";

    useEffect(() => {
        if (!debugVersions || !open) return;
        const selected = versies.find((v) => v.key === effectieveVersieKey) || null;
        console.log("[version-debug] versions-popup:state", {
            open,
            effectieveVersieKey,
            huidigeVersieKey,
            werkkopieVersieKey,
            kanHerstelVersie: Boolean(kanHerstelVersie),
            apryseActief: Boolean(apryseActief),
            selected: selected
                ? {
                      key: selected.key,
                      fase: selected.fase,
                      version: selected.version,
                      document_id: selected.document_id,
                  }
                : null,
        });
    }, [
        debugVersions,
        open,
        versies,
        effectieveVersieKey,
        huidigeVersieKey,
        werkkopieVersieKey,
        kanHerstelVersie,
        apryseActief,
    ]);

    if (!open) return null;

    return (
        <div
            role="dialog"
            aria-modal="true"
            aria-label="Documentversies"
            onClick={onClose}
            className={styles.overlay}
        >
            <div onClick={(e) => e.stopPropagation()} className={styles.panel}>
                <div className={`${styles.toolbarBase} ${styles.toolbar}`}>
                    <strong>Documentversies</strong>
                    <div className={styles.spacer} />
                    <button
                        className={styles.smallPrimary}
                        disabled={!kanHerstelVersie}
                        onClick={onHerstelVersie}
                    >
                        Terugzetten als werkkopie
                    </button>
                    <button className={styles.smallSecondary} onClick={onClose}>
                        Sluiten
                    </button>
                </div>

                <table className={`${styles.table} ${styles.versionsTable}`}>
                    <thead>
                        <tr>
                            <th>Fase</th>
                            <th>Label</th>
                            <th className={styles.actionCol}>Actie</th>
                        </tr>
                    </thead>
                    <tbody>
                        {versies.map((v) => {
                            const isActief = v.key === effectieveVersieKey;
                            const isHuidig = v.key === huidigeVersieKey;
                            const isWerkkopie = v.key === werkkopieVersieKey;
                            return (
                                <tr key={v.key}>
                                    <td className={styles.mono}>{v.fase}</td>
                                    <td>
                                        {v.label}
                                        {isHuidig && (
                                            <span className={styles.metaLabel}>(huidig)</span>
                                        )}
                                        {isActief && !isHuidig && !isWerkkopie && (
                                            <span className={styles.metaLabel}>
                                                (voorvertoning)
                                            </span>
                                        )}
                                        {isWerkkopie && (
                                            <span className={styles.metaLabel}>(werkkopie)</span>
                                        )}
                                    </td>
                                    <td>
                                        <button
                                            className={styles.smallSecondary}
                                            disabled={isActief}
                                            onClick={() => onKiesVersie(v.key)}
                                        >
                                            {isActief ? "Geselecteerd" : "Bekijken"}
                                        </button>
                                    </td>
                                </tr>
                            );
                        })}
                        {versies.length === 0 && (
                            <tr>
                                <td colSpan="3" className={styles.emptyCell}>
                                    Geen versies beschikbaar.
                                </td>
                            </tr>
                        )}
                    </tbody>
                </table>

                <h3 className={styles.snapshotsTitle}>Herstellen naar werkkopie (snapshots)</h3>
                <div className={styles.snapshotList}>
                    {snapshots.length === 0 && <p className={styles.empty}>Nog geen snapshots.</p>}
                    {snapshots.map((s) => (
                        <div className={styles.snapshot} key={s.snapshot_id}>
                            <div className={styles.author}>{s.snapshot_created_by}</div>
                            <div className={styles.timestamp}>
                                {new Date(s.snapshot_creation_date).toLocaleString("nl-NL")}
                            </div>
                            <button
                                className={`${styles.smallPrimary} ${styles.snapshotButton}`}
                                disabled={!apryseActief}
                                onClick={() => onHerstelSnapshot(s)}
                            >
                                Terugzetten als werkkopie
                            </button>
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/ViewerVlak/ViewerVlak.jsx =====
META: lines=57 bytes=1954
IMPORTS: ./ViewerVlak.module.css, react
EXPORTS: ViewerVlak
----- CODE START -----
import React from "react";
import styles from "./ViewerVlak.module.css";
export default function ViewerVlak({
    houderRef,
    viewerMeta,
    webviewerNodig,
    webviewerZichtbaar,
    status,
    foutmelding,
    contentUrl,
    docId,
}) {
    return (
        <div className={styles.viewerVlak}>
            {viewerMeta.laden && (
                <div className={styles.viewerPlaatsvervanger}>
                    <p>Viewer bepalen…</p>
                </div>
            )}

            {webviewerNodig && (
                <div
                    className={`${styles.webviewerHolder} ${webviewerZichtbaar ? styles.visible : styles.hidden}`}
                    ref={houderRef}
                />
            )}

            {webviewerZichtbaar && status !== "klaar" && (
                <div className={styles.viewerPlaatsvervanger}>
                    {status === "laden" && <p>Viewer laden…</p>}
                    {status === "geen-lib" && (
                        <>
                            <p className={styles.maxText}>
                                De Apryse WebViewer-assets zijn niet gevonden. In deze omgeving
                                worden ze geserveerd door de WebViewer-pod op{" "}
                                <code className={styles.inlineCode}>/webviewer/lib</code>.
                            </p>
                            <p className={styles.errorDetail}>Foutdetail: {foutmelding}</p>
                        </>
                    )}
                </div>
            )}

            {!viewerMeta.laden && !webviewerZichtbaar && (
                <div className={styles.nativeWrapper}>
                    <iframe
                        key={contentUrl}
                        title={`native-viewer-${docId}`}
                        src={contentUrl}
                        className={styles.nativeFrame}
                    />
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/beoordelen/WerkBalk/WerkBalk.jsx =====
META: lines=96 bytes=3256
IMPORTS: ./WerkBalk.module.css, react, react-router-dom
EXPORTS: WerkBalk
----- CODE START -----
import React from "react";
import { Link } from "react-router-dom";
import styles from "./WerkBalk.module.css";

export default function WerkBalk({
    caseId,
    lijstQuery,
    buren,
    onVorigDocument,
    onVolgendDocument,
    navigatieBezig,
    viewerMeta,
    versieLabel,
    previewModus,
    apryseActief,
    webviewerZichtbaar,
    wijzigingen,
    werkkopieActief,
    onTerugNaarHuidig,
    onOpenMenu,
}) {
    return (
        <section className={`${styles.section} ${styles.root}`}>
            <h3>Navigatie & document</h3>

            <button className={styles.smallSecondary} onClick={onOpenMenu} aria-label="Open menu">
                ☰ Menu
            </button>

            <Link to={`/zaken/${caseId}${lijstQuery ? `?${lijstQuery}` : ""}`}>
                ← Documentenlijst
            </Link>

            <div className={styles.blader}>
                <button
                    className={styles.smallSecondary}
                    onClick={onVorigDocument}
                    disabled={!buren?.vorige || navigatieBezig}
                    title={
                        buren?.vorige
                            ? `Vorige: ${buren.vorige.naam} (Alt+←)`
                            : "Dit is het eerste document"
                    }
                >
                    ← Vorige
                </button>
                <button
                    className={styles.smallSecondary}
                    onClick={onVolgendDocument}
                    disabled={!buren?.volgende || navigatieBezig}
                    title={
                        buren?.volgende
                            ? `Volgende: ${buren.volgende.naam} (Alt+→)`
                            : "Dit is het laatste document"
                    }
                >
                    Volgende →
                </button>
            </div>

            {Boolean(buren?.positie) && (
                <span className={styles.positie}>
                    {buren.positie} van {buren.totaal}
                </span>
            )}

            <div className={styles.doc} title={viewerMeta.naam || "Document"}>
                <strong>{viewerMeta.naam || "Document"}</strong>
                <span className={styles.docVersion}>
                    {viewerMeta.versie ? `v${viewerMeta.versie}` : ""}
                </span>
            </div>

            <span className={styles.versionLabel}>{versieLabel || "-"}</span>

            {werkkopieActief && (
                <button className={styles.smallSecondary} onClick={onTerugNaarHuidig}>
                    Terug naar huidige versie
                </button>
            )}

            <p className={styles.statusText}>
                {previewModus
                    ? "Voorvertoning (alleen lezen)"
                    : apryseActief
                      ? wijzigingen
                          ? "Wijzigingen worden opgeslagen…"
                          : "Alle wijzigingen opgeslagen"
                      : webviewerZichtbaar
                        ? "Viewer geladen"
                        : "Lezen in browserviewer"}
            </p>
        </section>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/documenten/DocumentenTabel/DocumentenTabel.jsx =====
META: lines=262 bytes=13820
IMPORTS: ../../../api.js, ./DocumentenTabel.module.css, react, react-router-dom
EXPORTS: DocumentenTabel
SYMBOLS: const-fn:sorteerTeken, fn:kanOpenenDocument
----- CODE START -----
import React from "react";
import { Link } from "react-router-dom";
import { FASE_LABELS } from "../../../api.js";
import styles from "./DocumentenTabel.module.css";

function kanOpenenDocument(doc) {
    return [
        "geconverteerd",
        "ai_gesuggereerd",
        "in_beoordeling",
        "gelakt",
        "geexporteerd",
    ].includes(String(doc?.fase || "").trim());
}

const OPENEN_TOOLTIP =
    "Dit document is nog niet geconverteerd. Converteer het document eerst voordat je het kunt openen.";

// key moet overeenkomen met SORT_EXPRESSIONS in live/routes/zaken.js (backend).
const KOLOMMEN = [
    { key: "volg_id", label: "Volg-id" },
    { key: "naam", label: "Naam" },
    { key: "type", label: "Type" },
    { key: "document_datum", label: "Documentdatum" },
    { key: "fase", label: "Fase" },
    { key: "versie", label: "Versie" },
    { key: "gronden", label: "Gronden", sorteerbaar: false },
    { key: "tags", label: "Tags" },
];

export default function DocumentenTabel({
    caseId,
    lijstQuery,
    items,
    selectie,
    bewerkt,
    concept,
    tag,
    sort,
    dir,
    onWisselSelectie,
    onAllesOpPagina,
    onSorteer,
    onStartBewerking,
    onBewaarBewerking,
    onAnnuleerBewerking,
    onConceptChange,
    onTagFilter,
    onVerwijderTag,
}) {
    const sorteerTeken = (kolom) => (sort === kolom ? (dir === "asc" ? "▲" : "▼") : "");

    return (
        <div className={styles.tableWrap}>
            <table className={styles.table}>
                <thead>
                    <tr>
                        <th
                            className={styles.selectCol}
                            onClick={onAllesOpPagina}
                            title="Alles op deze pagina"
                        >
                            <input
                                type="checkbox"
                                readOnly
                                checked={
                                    items.length > 0 &&
                                    items.every((d) => selectie.has(d.document_id))
                                }
                            />
                        </th>
                        {KOLOMMEN.map((kolom) =>
                            kolom.sorteerbaar === false ? (
                                <th key={kolom.key}>{kolom.label}</th>
                            ) : (
                                <th
                                    key={kolom.key}
                                    className={styles.sorteerbaar}
                                    role="button"
                                    tabIndex={0}
                                    aria-sort={
                                        sort === kolom.key
                                            ? dir === "asc"
                                                ? "ascending"
                                                : "descending"
                                            : "none"
                                    }
                                    title={`Sorteren op ${kolom.label}`}
                                    onClick={() => onSorteer(kolom.key)}
                                    onKeyDown={(e) => {
                                        if (e.key === "Enter" || e.key === " ") {
                                            e.preventDefault();
                                            onSorteer(kolom.key);
                                        }
                                    }}
                                >
                                    {kolom.label}
                                    <span className={styles.sorteerTeken} aria-hidden="true">
                                        {sorteerTeken(kolom.key)}
                                    </span>
                                </th>
                            )
                        )}
                        <th className={styles.actionsCol}>Acties</th>
                    </tr>
                </thead>
                <tbody>
                    {items.map((doc) => {
                        const inBewerking = bewerkt === doc.document_id;

                        return (
                            <tr
                                key={doc.document_id}
                                className={selectie.has(doc.document_id) ? styles.selectedRow : ""}
                            >
                                <td>
                                    <input
                                        type="checkbox"
                                        checked={selectie.has(doc.document_id)}
                                        onChange={() => onWisselSelectie(doc.document_id)}
                                        aria-label={`Selecteer ${doc.naam}`}
                                    />
                                </td>
                                <td className={styles.mono}>{doc.volg_id}</td>
                                <td>
                                    {inBewerking ? (
                                        <input
                                            type="text"
                                            value={concept.naam}
                                            onChange={(e) =>
                                                onConceptChange({ naam: e.target.value })
                                            }
                                            className={styles.fullInput}
                                        />
                                    ) : (
                                        <Link
                                            to={`/zaken/${caseId}/documenten/${doc.document_id}${
                                                lijstQuery ? `?${lijstQuery}` : ""
                                            }`}
                                        >
                                            {doc.naam}
                                        </Link>
                                    )}
                                </td>
                                <td className={styles.mono}>{doc.type}</td>
                                <td className={styles.mono}>
                                    {inBewerking ? (
                                        <input
                                            type="date"
                                            value={concept.document_datum}
                                            onChange={(e) =>
                                                onConceptChange({ document_datum: e.target.value })
                                            }
                                        />
                                    ) : (
                                        doc.document_datum
                                    )}
                                </td>
                                <td>
                                    <span
                                        className={`${styles.status} ${styles[`status_${doc.fase}`] || ""}`}
                                    >
                                        {FASE_LABELS[doc.fase]}
                                    </span>
                                </td>
                                <td className={styles.mono}>v{doc.versie}</td>
                                <td className={styles.mono}>{doc.gronden.join(", ")}</td>
                                <td>
                                    <span className={styles.tagChips}>
                                        {(doc.tags || []).map((t) => (
                                            <span
                                                key={t}
                                                className={`${styles.tagChip} ${tag === t ? styles.filterActive : ""}`}
                                            >
                                                <a
                                                    href="#"
                                                    onClick={(e) => {
                                                        e.preventDefault();
                                                        onTagFilter(t);
                                                    }}
                                                    className={styles.tagLink}
                                                    title={
                                                        tag === t
                                                            ? "Filter opheffen"
                                                            : `Filter op "${t}"`
                                                    }
                                                >
                                                    {t}
                                                </a>
                                                <button
                                                    aria-label={`Tag ${t} verwijderen`}
                                                    onClick={() =>
                                                        onVerwijderTag(doc.document_id, t)
                                                    }
                                                >
                                                    ×
                                                </button>
                                            </span>
                                        ))}
                                    </span>
                                </td>
                                <td>
                                    {inBewerking ? (
                                        <>
                                            <button
                                                className={styles.smallPrimary}
                                                onClick={() => onBewaarBewerking(doc)}
                                            >
                                                Opslaan
                                            </button>{" "}
                                            <button
                                                className={styles.smallSecondary}
                                                onClick={onAnnuleerBewerking}
                                            >
                                                Annuleren
                                            </button>
                                        </>
                                    ) : (
                                        <div className={styles.actionsContainer}>
                                            {kanOpenenDocument(doc) ? (
                                                <Link
                                                    to={`/zaken/${caseId}/documenten/${doc.document_id}${
                                                        lijstQuery ? `?${lijstQuery}` : ""
                                                    }`}
                                                >
                                                    <button className={styles.smallSecondary}>
                                                        Openen
                                                    </button>
                                                </Link>
                                            ) : (
                                                <button
                                                    className={`${styles.smallSecondary} ${styles.placeholder}`}
                                                >
                                                    Openen
                                                </button>
                                            )}
                                            <a
                                                href={`/api/documenten/${doc.document_id}/content`}
                                                download={doc.naam || "document.zip"}
                                                title="Downloaden"
                                                className={styles.downloadButton}
                                            >
                                                <svg
                                                    xmlns="http://www.w3.org/2000/svg"
                                                    viewBox="0 0 640 640"
                                                    className={styles.downloadIcon}
                                                >
                                                    <path d="M352 96C352 78.3 337.7 64 320 64C302.3 64 288 78.3 288 96L288 306.7L246.6 265.3C234.1 252.8 213.8 252.8 201.3 265.3C188.8 277.8 188.8 298.1 201.3 310.6L297.3 406.6C309.8 419.1 330.1 419.1 342.6 406.6L438.6 310.6C451.1 298.1 451.1 277.8 438.6 265.3C426.1 252.8 405.8 252.8 393.3 265.3L352 306.7L352 96zM160 384C124.7 384 96 412.7 96 448L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 448C544 412.7 515.3 384 480 384L433.1 384L376.5 440.6C345.3 471.8 294.6 471.8 263.4 440.6L206.9 384L160 384zM464 440C477.3 440 488 450.7 488 464C488 477.3 477.3 488 464 488C450.7 488 440 477.3 440 464C440 450.7 450.7 440 464 440z" />
                                                </svg>
                                            </a>
                                        </div>
                                    )}
                                </td>
                            </tr>
                        );
                    })}
                </tbody>
            </table>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/documenten/DocumentenWerkbalk/DocumentenWerkbalk.jsx =====
META: lines=175 bytes=6145
IMPORTS: ../../../api.js, ./DocumentenWerkbalk.module.css, react
EXPORTS: DocumentenWerkbalk
----- CODE START -----
import React, { useRef } from "react";
import { FASE_LABELS, VERBORGEN_IN_FILTER } from "../../../api.js";
import styles from "./DocumentenWerkbalk.module.css";

export default function DocumentenWerkbalk({
    fasen,
    tagOverzicht,
    q,
    fase,
    tag,
    tagInvoer,
    selectieAantal,
    filterSelectie,
    bezigMetUpload,
    bezigMetVerwijderen,
    bezigMetVerplaatsen,
    onZoek,
    onFaseChange,
    onTagChange,
    onTagInvoerChange,
    onTagToevoegen,
    onTagVerwijderen,
    onUpload,
    onVerwijderSelectie,
    onVerplaatsSelectie,
    onBatch,
    onSetPage,
}) {
    const bestandInput = useRef(null);

    return (
        <div className={styles.toolbar}>
            <input
                ref={bestandInput}
                type="file"
                multiple
                className={styles.hiddenInput}
                onChange={(e) => onUpload(e.target.files)}
                aria-label="Documenten kiezen om toe te voegen"
            />
            <button
                className={styles.smallPrimary}
                disabled={bezigMetUpload}
                onClick={() => bestandInput.current?.click()}
            >
                {bezigMetUpload ? "Bezig met uploaden…" : "+ Documenten toevoegen"}
            </button>

            <button
                className={styles.smallSecondary}
                disabled={selectieAantal === 0}
                onClick={() => onBatch("EXPORT_WITH_EXPORT_OPTIONS")}
            >
                Export selectie ({selectieAantal.toLocaleString("nl-NL")})
            </button>

            <button
                className={styles.smallSecondary}
                disabled={
                    selectieAantal === 0 || filterSelectie || bezigMetUpload || bezigMetVerplaatsen
                }
                onClick={onVerplaatsSelectie}
                title="Verplaats de selectie inclusief samenhangende varianten naar een andere zaak"
            >
                {bezigMetVerplaatsen
                    ? "Bezig met verplaatsen…"
                    : `Verplaats naar zaak (${selectieAantal.toLocaleString("nl-NL")})`}
            </button>

            <button
                className={styles.smallSecondary}
                disabled={
                    selectieAantal === 0 || filterSelectie || bezigMetUpload || bezigMetVerwijderen
                }
                onClick={onVerwijderSelectie}
            >
                {bezigMetVerwijderen
                    ? "Verwijderen…"
                    : `Verwijder selectie (${selectieAantal.toLocaleString("nl-NL")})`}
            </button>

            <input
                type="text"
                placeholder="Zoek op naam, bestandsnaam of volg-id…"
                defaultValue={q}
                onChange={(e) => onZoek(e.target.value)}
                className={styles.searchInput}
                aria-label="Zoeken"
            />

            <select
                value={fase}
                onChange={(e) => {
                    onFaseChange(e.target.value);
                    onSetPage(1);
                }}
                aria-label="Filter op fase"
            >
                <option value="">Alle fasen</option>
                {fasen
                    .filter((f) => !VERBORGEN_IN_FILTER.includes(f))
                    .map((f) => (
                        <option key={f} value={f}>
                            {FASE_LABELS[f]}
                        </option>
                    ))}
            </select>

            <select
                value={tag}
                onChange={(e) => {
                    onTagChange(e.target.value);
                    onSetPage(1);
                }}
                aria-label="Filter op tag"
            >
                <option value="">Alle tags</option>
                {tagOverzicht.map((t) => (
                    <option key={t.tag} value={t.tag}>
                        {t.tag} ({t.aantal})
                    </option>
                ))}
            </select>

            <span className={styles.tagInput}>
                <input
                    type="text"
                    placeholder="Tag…"
                    value={tagInvoer}
                    onChange={(e) => onTagInvoerChange(e.target.value)}
                    aria-label="Tagnaam voor selectie"
                />
                <button
                    className={styles.smallSecondary}
                    disabled={selectieAantal === 0 || !tagInvoer.trim()}
                    onClick={() => onTagToevoegen(tagInvoer)}
                >
                    + Tag ({selectieAantal.toLocaleString("nl-NL")})
                </button>
                <button
                    className={styles.smallSecondary}
                    disabled={selectieAantal === 0 || !tagInvoer.trim()}
                    onClick={() => onTagVerwijderen(tagInvoer)}
                >
                    − Tag ({selectieAantal.toLocaleString("nl-NL")})
                </button>
            </span>

            <div className={styles.spacer} />

            <button
                className={styles.smallSecondary}
                disabled={selectieAantal === 0}
                onClick={() => onBatch("CONVERT_DOCUMENT")}
            >
                Opnieuw converteren ({selectieAantal.toLocaleString("nl-NL")})
            </button>
            <button
                className={styles.smallSecondary}
                disabled={selectieAantal === 0}
                onClick={() => onBatch("CREATE_ANNOTATION")}
            >
                AI-suggesties ({selectieAantal.toLocaleString("nl-NL")})
            </button>
            <button
                className={styles.smallLak}
                disabled={selectieAantal === 0}
                onClick={() => onBatch("REDACT_DOCUMENT")}
            >
                Definitief lakken ({selectieAantal.toLocaleString("nl-NL")})
            </button>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/documenten/Paginering/Paginering.jsx =====
META: lines=58 bytes=1847
IMPORTS: ./Paginering.module.css, react
EXPORTS: Paginering
----- CODE START -----
import React from "react";
import styles from "./Paginering.module.css";

const PAGINAGROOTTES = [50, 100, 200];

export default function Paginering({ page, pageSize, totaal, totaalPaginas, onPage, onPageSize }) {
    return (
        <div className={styles.root}>
            <span>{totaal.toLocaleString("nl-NL")} documenten</span>
            <select
                value={pageSize}
                onChange={(e) => {
                    onPageSize(Number(e.target.value));
                    onPage(1);
                }}
                aria-label="Paginagrootte"
            >
                {PAGINAGROOTTES.map((g) => (
                    <option key={g} value={g}>
                        {g} per pagina
                    </option>
                ))}
            </select>
            <button
                className={styles.smallSecondary}
                disabled={page <= 1}
                onClick={() => onPage(1)}
            >
                «
            </button>
            <button
                className={styles.smallSecondary}
                disabled={page <= 1}
                onClick={() => onPage(page - 1)}
            >
                Vorige
            </button>
            <span className={`${styles.mono} ${styles.pageInfo}`}>
                pagina {page} / {totaalPaginas.toLocaleString("nl-NL")}
            </span>
            <button
                className={styles.smallSecondary}
                disabled={page >= totaalPaginas}
                onClick={() => onPage(page + 1)}
            >
                Volgende
            </button>
            <button
                className={styles.smallSecondary}
                disabled={page >= totaalPaginas}
                onClick={() => onPage(totaalPaginas)}
            >
                »
            </button>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/documenten/useDocumentenData.js =====
META: lines=173 bytes=5980
IMPORTS: ../../api.js, react
EXPORTS: useDocumentenData
SYMBOLS: const-fn:flushRefreshWindow, const-fn:onConnected, const-fn:onFocusOrVisible, const-fn:onRefresh, const-fn:refreshNow, const-fn:scheduleWindowFlush, fn:useDocumentenData
----- CODE START -----
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../api.js";

const SSE_WINDOW_MS = 400;
const FALLBACK_REFRESH_MS = 60000;

export function useDocumentenData(caseId, { page, pageSize, q, fase, tag, sort, dir }) {
    const [zaak, setZaak] = useState(null);
    const [data, setData] = useState(null);
    const [fout, setFout] = useState(null);
    const [tagOverzicht, setTagOverzicht] = useState([]);

    const sseWindowTimer = useRef(null);
    const sseWindowPending = useRef(false);
    const sseHighestSeq = useRef(0);
    const sseAppliedSeq = useRef(0);
    const sseRefreshInFlight = useRef(false);

    const laad = useCallback(() => {
        return api
            .documenten(caseId, { page, pageSize, q, fase, tag, sort, dir })
            .then((d) => {
                setData(d);
                setFout(null);
            })
            .catch((e) => setFout(e.message));
    }, [caseId, page, pageSize, q, fase, tag, sort, dir]);

    const laadZaak = useCallback(() => {
        return api
            .zaak(caseId)
            .then(setZaak)
            .catch(() => {});
    }, [caseId]);

    const laadTags = useCallback(() => {
        return api
            .tags(caseId)
            .then(setTagOverzicht)
            .catch(() => setTagOverzicht([]));
    }, [caseId]);

    useEffect(() => {
        laadZaak();
    }, [laadZaak]);

    useEffect(() => {
        laadTags();
    }, [laadTags]);

    useEffect(() => {
        laad();
    }, [laad]);

    useEffect(() => {
        const es = new EventSource(`/api/events?caseId=${encodeURIComponent(caseId)}`);

        const flushRefreshWindow = async () => {
            if (sseRefreshInFlight.current) {
                sseWindowPending.current = true;
                if (!sseWindowTimer.current) {
                    sseWindowTimer.current = setTimeout(() => {
                        sseWindowTimer.current = null;
                        flushRefreshWindow();
                    }, SSE_WINDOW_MS);
                }
                return;
            }

            const targetSeq = sseHighestSeq.current;
            if (targetSeq <= sseAppliedSeq.current) return;

            sseRefreshInFlight.current = true;
            try {
                await Promise.allSettled([laad(), laadZaak(), laadTags()]);
            } finally {
                sseAppliedSeq.current = targetSeq;
                sseRefreshInFlight.current = false;
                if (sseHighestSeq.current > sseAppliedSeq.current) {
                    sseWindowPending.current = true;
                    if (!sseWindowTimer.current) {
                        sseWindowTimer.current = setTimeout(() => {
                            sseWindowTimer.current = null;
                            flushRefreshWindow();
                        }, SSE_WINDOW_MS);
                    }
                }
            }
        };

        const scheduleWindowFlush = () => {
            sseWindowPending.current = true;
            if (sseWindowTimer.current) return;
            sseWindowTimer.current = setTimeout(() => {
                sseWindowTimer.current = null;
                if (!sseWindowPending.current) return;
                sseWindowPending.current = false;
                flushRefreshWindow();
            }, SSE_WINDOW_MS);
        };

        const onConnected = (evt) => {
            try {
                const payload = JSON.parse(evt?.data || "{}");
                const seq = Number(payload?.seq || 0);
                if (Number.isFinite(seq) && seq > 0) {
                    sseHighestSeq.current = Math.max(sseHighestSeq.current, seq);
                    sseAppliedSeq.current = Math.max(sseAppliedSeq.current, seq);
                }
            } catch {}
        };

        const onRefresh = (evt) => {
            let seq = 0;
            try {
                seq = Number(JSON.parse(evt?.data || "{}")?.seq || 0);
            } catch {
                seq = 0;
            }
            if (!Number.isFinite(seq) || seq <= 0) seq = sseHighestSeq.current + 1;
            sseHighestSeq.current = Math.max(sseHighestSeq.current, seq);
            scheduleWindowFlush();
        };

        es.addEventListener("connected", onConnected);
        es.addEventListener("refresh", onRefresh);
        es.onerror = () => {};

        return () => {
            clearTimeout(sseWindowTimer.current);
            sseWindowTimer.current = null;
            sseWindowPending.current = false;
            sseRefreshInFlight.current = false;
            es.removeEventListener("connected", onConnected);
            es.removeEventListener("refresh", onRefresh);
            es.close();
        };
    }, [caseId, laad, laadZaak, laadTags]);

    useEffect(() => {
        const refreshNow = async () => {
            if (sseRefreshInFlight.current) return;
            const targetSeq = sseHighestSeq.current;
            sseRefreshInFlight.current = true;
            try {
                await Promise.allSettled([laad(), laadZaak(), laadTags()]);
            } finally {
                sseAppliedSeq.current = Math.max(sseAppliedSeq.current, targetSeq);
                sseRefreshInFlight.current = false;
            }
        };

        const onFocusOrVisible = () => {
            if (document.visibilityState === "visible") refreshNow();
        };

        const timer = setInterval(() => {
            if (document.visibilityState === "visible") refreshNow();
        }, FALLBACK_REFRESH_MS);

        window.addEventListener("focus", onFocusOrVisible);
        document.addEventListener("visibilitychange", onFocusOrVisible);

        return () => {
            clearInterval(timer);
            window.removeEventListener("focus", onFocusOrVisible);
            document.removeEventListener("visibilitychange", onFocusOrVisible);
        };
    }, [laad, laadTags]);

    return { zaak, data, fout, tagOverzicht, laad, laadZaak, laadTags };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/documenten/ZaakStatus/ZaakStatus.jsx =====
META: lines=270 bytes=10833
IMPORTS: ../../../api.js, ./ZaakStatus.module.css, react
EXPORTS: ZaakStatus
SYMBOLS: const-fn:herstelQuarantaine, const-fn:opEvent, fn:tijdKort
----- CODE START -----
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../../api.js";
import styles from "./ZaakStatus.module.css";

const ICOON = {
    bezig: "◐",
    wacht: "☐",
    rust: "●",
    waarschuwing: "▲",
    fout: "■",
    gepauzeerd: "❚❚",
};

function tijdKort(iso) {
    if (!iso) return "";
    const d = new Date(iso);
    if (Number.isNaN(d.getTime())) return "";
    return d.toLocaleString("nl-NL", {
        day: "2-digit",
        month: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
    });
}

/**
 * Toont in één regel of het systeem werk onderhanden heeft, met details achter
 * een klik. Bedoeld om het teruggrijpen op pod-logs overbodig te maken.
 *
 * @param {string} caseId
 * @param {number} versie  ophogen om een verse ophaal te forceren (bv. na SSE)
 */
export default function ZaakStatus({ caseId, versie = 0 }) {
    const [status, setStatus] = useState(null);
    const [fout, setFout] = useState(null);
    const [open, setOpen] = useState(false);
    const [herstelBezig, setHerstelBezig] = useState(false);
    const [herstelMelding, setHerstelMelding] = useState(null);
    const laatsteRef = useRef(null);

    const laad = useCallback(async () => {
        if (!caseId) return;
        try {
            const data = await api.zaakStatus(caseId);
            setStatus(data);
            setFout(null);
            laatsteRef.current = new Date();
        } catch (e) {
            setFout(e.message);
        }
    }, [caseId]);

    // Na een actie van de gebruiker (upload, batchactie, verwijderen) duurt het
    // even voordat de orchestrator werk heeft opgepakt. Eén keer verversen laat
    // dan nog "niets actief" zien; daarom een korte reeks metingen.
    const burst = useCallback(() => {
        laad();
        const timers = [800, 2000, 4000, 8000].map((ms) => setTimeout(laad, ms));
        return () => timers.forEach(clearTimeout);
    }, [laad]);

    useEffect(() => burst(), [burst, versie]);

    // Blijf verversen zolang er werk loopt: de looptijd van batches moet
    // meelopen, ook zonder nieuwe events.
    useEffect(() => {
        const interval = status?.bezig ? 5000 : 20000;
        const timer = setInterval(laad, interval);
        return () => clearInterval(timer);
    }, [laad, status?.bezig]);

    // Server-side events: zodra de orchestrator iets meldt, direct bijwerken.
    useEffect(() => {
        if (!caseId) return undefined;

        const es = new EventSource(`/api/events?caseId=${encodeURIComponent(caseId)}`);
        let wachtend = null;

        const opEvent = () => {
            if (wachtend) return;
            wachtend = setTimeout(() => {
                wachtend = null;
                laad();
            }, 300);
        };

        es.onmessage = opEvent;
        es.addEventListener("refresh", opEvent);
        es.onerror = () => {};

        return () => {
            if (wachtend) clearTimeout(wachtend);
            es.close();
        };
    }, [caseId, laad]);

    const herstelQuarantaine = async () => {
        if (herstelBezig) return;
        const aantal = status?.quarantaine?.length || 0;
        const bevestigd = window.confirm(
            `${aantal} document(en) uit quarantaine opnieuw aanbieden aan DRS? ` +
                `De pogingenteller wordt op nul gezet.`
        );
        if (!bevestigd) return;

        setHerstelBezig(true);
        setHerstelMelding(null);
        try {
            const data = await api.quarantaineOpnieuw(caseId);
            setHerstelMelding(`${data?.hersteld ?? 0} document(en) opnieuw in de wachtrij gezet.`);
            burst();
        } catch (e) {
            setHerstelMelding(`Opnieuw proberen mislukt: ${e.message}`);
        } finally {
            setHerstelBezig(false);
        }
    };

    if (fout && !status) {
        return (
            <div className={`${styles.balk} ${styles.fout}`}>
                <span className={styles.icoon}>■</span>
                <span className={styles.kern}>Status niet beschikbaar</span>
            </div>
        );
    }

    if (!status) {
        return (
            <div className={styles.balk}>
                <span className={styles.icoon}>◌</span>
                <span className={styles.kern}>Status ophalen…</span>
            </div>
        );
    }

    const niveau = status.niveau || "rust";
    const heeftDetails =
        status.batches_open?.length ||
        status.quarantaine?.length ||
        status.aandacht?.length ||
        status.fasen;

    return (
        <div className={`${styles.balk} ${styles[niveau] || ""}`}>
            <button
                type="button"
                className={styles.kop}
                onClick={() => setOpen((v) => !v)}
                aria-expanded={open}
                title="Klik voor details"
            >
                <span
                    className={`${styles.icoon} ${niveau === "bezig" ? styles.draait : ""}`}
                    aria-hidden="true"
                >
                    {ICOON[niveau] || "●"}
                </span>
                <span className={styles.kern}>{status.kern}</span>
                {status.aandacht?.length > 0 && (
                    <span className={styles.badge}>{status.aandacht.length}</span>
                )}
                {heeftDetails ? (
                    <span className={styles.pijl} aria-hidden="true">
                        {open ? "▴" : "▾"}
                    </span>
                ) : null}
            </button>

            {open && (
                <div className={styles.details}>
                    {status.aandacht?.length > 0 && (
                        <ul className={styles.aandacht}>
                            {status.aandacht.map((regel, i) => (
                                <li key={i}>{regel}</li>
                            ))}
                        </ul>
                    )}

                    <div className={styles.blok}>
                        <h4>Voortgang per fase</h4>
                        <table className={styles.tabel}>
                            <thead>
                                <tr>
                                    <th>Fase</th>
                                    <th>In wachtrij</th>
                                    <th>Onderhanden</th>
                                    <th>Gereed</th>
                                    <th>Quarantaine</th>
                                </tr>
                            </thead>
                            <tbody>
                                {Object.entries(status.fasen || {}).map(([fase, t]) => (
                                    <tr
                                        key={fase}
                                        className={fase === status.fase_actief ? styles.actief : ""}
                                    >
                                        <td>
                                            {fase}
                                            {t.handmatig && (
                                                <span className={styles.handmatig}>
                                                    {" "}
                                                    handmatig
                                                </span>
                                            )}
                                        </td>
                                        <td>{t.nodig}</td>
                                        <td>{t.verzonden}</td>
                                        <td>{t.gereed}</td>
                                        <td className={t.quarantaine ? styles.rood : ""}>
                                            {t.quarantaine}
                                        </td>
                                    </tr>
                                ))}
                            </tbody>
                        </table>
                    </div>

                    {status.batches_open?.length > 0 && (
                        <div className={styles.blok}>
                            <h4>Lopende batches</h4>
                            <ul className={styles.lijst}>
                                {status.batches_open.map((b, i) => (
                                    <li key={i} className={b.traag ? styles.rood : ""}>
                                        {b.fase}: {b.aantal} document(en), loopt al {b.loopt_al}
                                        {b.traag ? " — mogelijk vastgelopen" : ""}
                                    </li>
                                ))}
                            </ul>
                        </div>
                    )}

                    {status.quarantaine?.length > 0 && (
                        <div className={styles.blok}>
                            <h4>Quarantaine ({status.quarantaine.length})</h4>
                            <button
                                type="button"
                                className={styles.herstel}
                                onClick={herstelQuarantaine}
                                disabled={herstelBezig}
                            >
                                {herstelBezig ? "Bezig…" : "Opnieuw proberen"}
                            </button>
                            {herstelMelding && (
                                <div className={styles.foutregel}>{herstelMelding}</div>
                            )}
                            <ul className={styles.lijst}>
                                {status.quarantaine.map((doc) => (
                                    <li key={`${doc.fase}-${doc.document_id}`}>
                                        <strong>{doc.naam}</strong> — {doc.fase}, {doc.pogingen}{" "}
                                        poging(en), sinds {tijdKort(doc.sinds)}
                                        {doc.fout && <div className={styles.foutregel}>{doc.fout}</div>}
                                    </li>
                                ))}
                            </ul>
                        </div>
                    )}

                    <div className={styles.voet}>
                        Orchestrator: {status.componenten?.orchestrator?.status || "?"}
                        {status.componenten?.orchestrator?.schijfruimte === "vol" && ", schijf vol"}
                        {" · "}DRS: {status.componenten?.drs || "?"}
                        {" · "}bijgewerkt {tijdKort(status.ts)}
                    </div>
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/components/zaken/FaseBalk/FaseBalk.jsx =====
META: lines=81 bytes=3221
IMPORTS: ../../../api.js, ./FaseBalk.module.css, react
EXPORTS: FaseBalk
SYMBOLS: const-fn:breedte
----- CODE START -----
import React from "react";
import { FASE_LABELS, VERBORGEN_IN_VOORTGANG } from "../../../api.js";
import styles from "./FaseBalk.module.css";

/**
 * Voortgang van een zaak als één gestapelde balk.
 *
 * Elk segment is zo breed als het aandeel documenten dat in die fase zit, dus de
 * balk is samen altijd 100%. De vorige opzet tekende per fase een eigen balkje
 * met een cumulatief percentage, wat las als vijf losse voortgangsbalken.
 *
 * Verborgen fasen (zie VERBORGEN_IN_VOORTGANG) krijgen geen eigen segment maar
 * worden opgeteld bij de laatste zichtbare fase ervoor: geexporteerde documenten
 * zijn immers ook definitief gelakt en mogen niet uit de telling vallen.
 */
export default function FaseBalk({ tellers, fasen, totaal }) {
    const veilig = Math.max(0, Number(totaal) || 0);

    const groepen = [];
    for (const fase of fasen) {
        const aantal = Number(tellers?.[fase]) || 0;
        if (VERBORGEN_IN_VOORTGANG.includes(fase)) {
            if (groepen.length) groepen[groepen.length - 1].aantal += aantal;
            continue;
        }
        groepen.push({ fase, aantal });
    }

    const inFase = groepen.reduce((som, g) => som + g.aantal, 0);
    const nietGestart = Math.max(0, veilig - inFase);

    const laatste = groepen[groepen.length - 1];
    const gereed = laatste ? laatste.aantal : 0;
    const percentage = veilig ? Math.round((gereed / veilig) * 100) : 0;

    const tooltip = [
        ...groepen.map((g) => `${FASE_LABELS[g.fase] || g.fase}: ${g.aantal}`),
        ...(nietGestart ? [`Nog niet gestart: ${nietGestart}`] : []),
    ].join(" · ");

    const breedte = (aantal) => (veilig ? `${(aantal / veilig) * 100}%` : "0%");

    return (
        <div className={styles.root} title={tooltip}>
            <div
                className={styles.balk}
                role="img"
                aria-label={`Voortgang: ${tooltip || "geen documenten"}`}
            >
                {groepen.map((groep, index) =>
                    groep.aantal > 0 ? (
                        <div
                            key={groep.fase}
                            className={`${styles.deel} ${styles[`fase${index}`] || ""}`}
                            style={{ width: breedte(groep.aantal) }}
                        />
                    ) : null
                )}
                {nietGestart > 0 && (
                    <div className={styles.rest} style={{ width: breedte(nietGestart) }} />
                )}
            </div>

            <div className={styles.onderschrift}>
                {veilig === 0 ? (
                    <span className={styles.zacht}>Geen documenten</span>
                ) : (
                    <>
                        <strong>{percentage}%</strong>
                        <span className={styles.zacht}>
                            {" "}
                            {FASE_LABELS[laatste?.fase]?.toLowerCase() || "gereed"} (
                            {gereed.toLocaleString("nl-NL")} van {veilig.toLocaleString("nl-NL")})
                        </span>
                    </>
                )}
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/main.jsx =====
META: lines=19 bytes=806
IMPORTS: ./App/App.jsx, ./styles.css, react, react-dom/client, react-router-dom
----- CODE START -----
import React from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App/App.jsx";
import "./styles.css";

// Data-router in plaats van <BrowserRouter>: alleen een data-router levert de
// context die useBlocker nodig heeft. Dat gebruiken we in Beoordelen om bij het
// weg navigeren te vragen of de wijzigingen in de werkkopie vastgelegd moeten
// worden of genegeerd mogen worden. De bestaande <Routes>-boom in App blijft
// ongewijzigd draaien onder deze catch-all route.
const router = createBrowserRouter([{ path: "*", element: <App /> }]);

createRoot(document.getElementById("root")).render(
    <React.StrictMode>
        <RouterProvider router={router} />
    </React.StrictMode>
);

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Annotaties/Annotaties.jsx =====
META: lines=391 bytes=18964
IMPORTS: ../../api.js, ./Annotaties.module.css, react, react-router-dom
EXPORTS: Annotaties
SYMBOLS: const-fn:naarWhitelist, const-fn:varianten, fn:Kaart
----- CODE START -----
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { api } from "../../api.js";
import styles from "./Annotaties.module.css";

const HERKOMST_LABEL = {
    ai: "AI",
    custom: "Eigen term",
    handmatig: "Handmatig",
};

function Kaart({ waarde, label, toon }) {
    return (
        <div className={styles.kaart}>
            <div className={styles.kaartWaarde}>{Number(waarde || 0).toLocaleString("nl-NL")}</div>
            <div className={styles.kaartLabel}>{label}</div>
            {toon ? <div className={styles.kaartExtra}>{toon}</div> : null}
        </div>
    );
}

export default function Annotaties() {
    const { caseId } = useParams();

    const [data, setData] = useState(null);
    const [fout, setFout] = useState(null);
    const [bezig, setBezig] = useState(false);
    const [fase, setFase] = useState("converteren");
    const [tab, setTab] = useState("documenten");
    const [typeFilter, setTypeFilter] = useState("");
    const [herkomstFilter, setHerkomstFilter] = useState("");
    const [zoek, setZoek] = useState("");
    const [alleenZonder, setAlleenZonder] = useState(false);
    const [melding, setMelding] = useState(null);
    const [bezigWhitelist, setBezigWhitelist] = useState(null);

    const toon = useCallback((tekst) => {
        setMelding(tekst);
        setTimeout(() => setMelding(null), 3500);
    }, []);

    const laad = useCallback(
        async (ververs = false) => {
            setBezig(true);
            try {
                const res = await api.annotatieOverzicht(caseId, { fase, ververs });
                setData(res);
                setFout(null);
            } catch (e) {
                setFout(e.message);
            } finally {
                setBezig(false);
            }
        },
        [caseId, fase]
    );

    useEffect(() => {
        laad();
    }, [laad]);

    const documenten = useMemo(() => {
        let lijst = data?.documenten || [];
        if (alleenZonder) lijst = lijst.filter((d) => d.geanalyseerd && d.annotaties === 0);
        if (typeFilter) lijst = lijst.filter((d) => (d.typen || {})[typeFilter]);
        if (herkomstFilter) lijst = lijst.filter((d) => (d.herkomst || {})[herkomstFilter]);
        if (zoek.trim()) {
            const z = zoek.trim().toLowerCase();
            lijst = lijst.filter((d) => (d.naam || "").toLowerCase().includes(z));
        }
        return lijst;
    }, [data, alleenZonder, typeFilter, herkomstFilter, zoek]);

    const waarden = useMemo(() => {
        let lijst = data?.top_waarden || [];
        if (typeFilter) lijst = lijst.filter((w) => w.type === typeFilter);
        if (herkomstFilter) lijst = lijst.filter((w) => (w.herkomst || "").includes(herkomstFilter));
        if (zoek.trim()) {
            const z = zoek.trim().toLowerCase();
            lijst = lijst.filter((w) => (w.waarde || "").toLowerCase().includes(z));
        }
        return lijst;
    }, [data, typeFilter, herkomstFilter, zoek]);

    const naarWhitelist = async (waarde) => {
        const varianten = (waarde.varianten || []).map((v) => v.tekst);
        const bevestigd = window.confirm(
            `"${waarde.waarde}" toevoegen aan de whitelist?\n\n` +
                (varianten.length > 1
                    ? `Alle ${varianten.length} schrijfwijzen worden toegevoegd: ${varianten.join(", ")}\n\n`
                    : "") +
                "Deze term wordt voortaan niet meer als lakking voorgesteld."
        );
        if (!bevestigd) return;

        setBezigWhitelist(waarde.waarde);
        try {
            for (const tekst of varianten) {
                await api.beheer.lijstActie("whitelist", "add", tekst);
            }
            toon(`"${waarde.waarde}" toegevoegd aan de whitelist.`);
        } catch (e) {
            toon(`Toevoegen mislukt: ${e.message}`);
        } finally {
            setBezigWhitelist(null);
        }
    };

    if (fout && !data) {
        return (
            <div className={styles.pagina}>
                <p className={styles.fout}>Overzicht kon niet worden geladen: {fout}</p>
                <button className="klein" onClick={() => laad(true)}>
                    Opnieuw proberen
                </button>
            </div>
        );
    }

    return (
        <div className={styles.pagina}>
            <div className={styles.kop}>
                <div>
                    <h2>Lakken in deze zaak</h2>
                    <p className={styles.subkop}>
                        <Link to={`/zaken/${encodeURIComponent(caseId)}`}>← terug naar documenten</Link>
                    </p>
                </div>
                <div className={styles.kopActies}>
                    <label className={styles.veld}>
                        Fase
                        <select value={fase} onChange={(e) => setFase(e.target.value)}>
                            {(data?.fasen_beschikbaar || ["converteren"]).map((f) => (
                                <option key={f} value={f}>
                                    {f}
                                </option>
                            ))}
                            <option value="alle">alle fasen</option>
                        </select>
                    </label>
                    <button className="klein secundair" disabled={bezig} onClick={() => laad(true)}>
                        {bezig ? "Bezig…" : "Vernieuwen"}
                    </button>
                </div>
            </div>

            {melding && <div className={styles.melding}>{melding}</div>}

            {data && (
                <>
                    <div className={styles.kaarten}>
                        <Kaart waarde={data.documenten_totaal} label="documenten in deze zaak" />
                        <Kaart
                            waarde={data.documenten_met_annotaties}
                            label="documenten met minstens één lakking"
                            toon={`${data.documenten_zonder_annotaties} document(en) zonder lakking`}
                        />
                        <Kaart
                            waarde={data.annotaties_totaal}
                            label="lakkingen in totaal"
                            toon="alle documenten bij elkaar opgeteld"
                        />
                        <Kaart
                            waarde={data.per_herkomst?.ai || 0}
                            label="lakkingen voorgesteld door de AI"
                            toon={[
                                data.per_herkomst?.custom
                                    ? `${data.per_herkomst.custom} eigen term`
                                    : null,
                                data.per_herkomst?.handmatig
                                    ? `${data.per_herkomst.handmatig} handmatig`
                                    : null,
                            ]
                                .filter(Boolean)
                                .join(", ")}
                        />
                    </div>

                    {data.documenten_niets_gevonden > 0 && (
                        <div className={styles.attentie}>
                            <strong>{data.documenten_niets_gevonden}</strong> document(en) zijn wel
                            geanalyseerd maar leverden geen enkele lakking op. Controleer of dat
                            terecht is.{" "}
                            <button
                                className={styles.link}
                                onClick={() => {
                                    setAlleenZonder(true);
                                    setTab("documenten");
                                }}
                            >
                                Toon deze documenten
                            </button>
                        </div>
                    )}

                    {data.duplicaten_samengevoegd > 0 && (
                        <p className={styles.notitie}>
                            {data.duplicaten_samengevoegd} document(en) met een dubbele naam zijn
                            samengevoegd; de aantallen hieronder tellen elk document één keer.
                        </p>
                    )}

                    <div className={styles.filters}>
                        <input
                            placeholder="Zoek op naam of waarde…"
                            value={zoek}
                            onChange={(e) => setZoek(e.target.value)}
                        />
                        <select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
                            <option value="">alle typen</option>
                            {(data.per_type || []).map((t) => (
                                <option key={t.type} value={t.type}>
                                    {t.type} ({t.annotaties})
                                </option>
                            ))}
                        </select>
                        <select
                            value={herkomstFilter}
                            onChange={(e) => setHerkomstFilter(e.target.value)}
                        >
                            <option value="">alle herkomst</option>
                            {Object.keys(data.per_herkomst || {}).map((h) => (
                                <option key={h} value={h}>
                                    {HERKOMST_LABEL[h] || h}
                                </option>
                            ))}
                        </select>
                        {alleenZonder && (
                            <button className={styles.link} onClick={() => setAlleenZonder(false)}>
                                filter &quot;zonder lakken&quot; opheffen
                            </button>
                        )}
                    </div>

                    <div className={styles.tabs}>
                        <button
                            className={tab === "documenten" ? styles.tabActief : styles.tab}
                            onClick={() => setTab("documenten")}
                        >
                            Per document ({documenten.length})
                        </button>
                        <button
                            className={tab === "waarden" ? styles.tabActief : styles.tab}
                            onClick={() => setTab("waarden")}
                        >
                            Gelakte waarden ({waarden.length})
                        </button>
                    </div>

                    {tab === "documenten" ? (
                        <div className={styles.tabelWrap}>
                        <table className={styles.tabel}>
                            <thead>
                                <tr>
                                    <th>Document</th>
                                    <th className={styles.rechts}>Lakken</th>
                                    <th>Typen</th>
                                    <th>Herkomst</th>
                                    <th>Bron</th>
                                </tr>
                            </thead>
                            <tbody>
                                {documenten.map((d) => (
                                    <tr key={d.document_id}>
                                        <td>
                                            <Link
                                                to={`/zaken/${encodeURIComponent(caseId)}/documenten/${d.document_id}`}
                                            >
                                                {d.naam}
                                            </Link>
                                            {d.volg_id ? (
                                                <span className={styles.zacht}> · {d.volg_id}</span>
                                            ) : null}
                                        </td>
                                        <td className={styles.rechts}>
                                            {d.annotaties === 0 ? (
                                                <span className={styles.nul}>0</span>
                                            ) : (
                                                d.annotaties
                                            )}
                                        </td>
                                        <td>
                                            {Object.entries(d.typen || {}).map(([t, n]) => (
                                                <span key={t} className={styles.chip}>
                                                    {t} {n}
                                                </span>
                                            ))}
                                        </td>
                                        <td>
                                            {Object.entries(d.herkomst || {}).map(([h, n]) => (
                                                <span key={h} className={styles.chipZacht}>
                                                    {HERKOMST_LABEL[h] || h} {n}
                                                </span>
                                            ))}
                                        </td>
                                        <td className={styles.zacht}>
                                            {d.bron === "werkkopie"
                                                ? "beoordeeld"
                                                : d.bron === "ai_snapshot"
                                                  ? "AI-analyse"
                                                  : "geen analyse"}
                                        </td>
                                    </tr>
                                ))}
                                {documenten.length === 0 && (
                                    <tr>
                                        <td colSpan={5} className={styles.leeg}>
                                            Geen documenten die aan de filters voldoen.
                                        </td>
                                    </tr>
                                )}
                            </tbody>
                        </table>
                        </div>
                    ) : (
                        <div className={styles.tabelWrap}>
                        <table className={styles.tabel}>
                            <thead>
                                <tr>
                                    <th>Waarde</th>
                                    <th>Type</th>
                                    <th className={styles.rechts}>Keer</th>
                                    <th className={styles.rechts}>Documenten</th>
                                    <th>Herkomst</th>
                                    <th />
                                </tr>
                            </thead>
                            <tbody>
                                {waarden.map((w) => (
                                    <tr key={`${w.type}-${w.waarde}`}>
                                        <td>
                                            <strong>{w.waarde}</strong>
                                            {w.varianten?.length > 1 && (
                                                <div className={styles.zacht}>
                                                    ook geschreven als{" "}
                                                    {w.varianten
                                                        .slice(1)
                                                        .map((v) => `${v.tekst} (${v.aantal}×)`)
                                                        .join(", ")}
                                                </div>
                                            )}
                                        </td>
                                        <td>
                                            <span className={styles.chip}>{w.type}</span>
                                        </td>
                                        <td className={styles.rechts}>{w.aantal}</td>
                                        <td className={styles.rechts}>{w.documenten}</td>
                                        <td className={styles.zacht}>
                                            {(w.herkomst || "")
                                                .split("+")
                                                .map((h) => HERKOMST_LABEL[h] || h)
                                                .join(" + ")}
                                        </td>
                                        <td>
                                            <button
                                                className={styles.link}
                                                disabled={bezigWhitelist === w.waarde}
                                                onClick={() => naarWhitelist(w)}
                                                title="Zet deze term op de whitelist: hij wordt voortaan niet meer voorgesteld"
                                            >
                                                {bezigWhitelist === w.waarde
                                                    ? "bezig…"
                                                    : "Voeg toe aan whitelist"}
                                            </button>
                                        </td>
                                    </tr>
                                ))}
                                {waarden.length === 0 && (
                                    <tr>
                                        <td colSpan={6} className={styles.leeg}>
                                            Geen waarden die aan de filters voldoen.
                                        </td>
                                    </tr>
                                )}
                            </tbody>
                        </table>
                        </div>
                    )}

                    <p className={styles.voet}>
                        Bijgewerkt {new Date(data.ts).toLocaleString("nl-NL")}
                        {data.uit_cache ? " (uit cache)" : ""} · {data.snapshots_gebruikt} document(en)
                        via AI-analyse ingelezen
                        {data.snapshots_mislukt ? `, ${data.snapshots_mislukt} mislukt` : ""}
                    </p>
                </>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Beheer/Beheer.jsx =====
META: lines=78 bytes=3306
IMPORTS: ../../components/beheer/AppKleuren/AppKleuren.jsx, ../../components/beheer/Audit/Audit.jsx, ../../components/beheer/CustomEntities/CustomEntities.jsx, ../../components/beheer/ExportKolommen/ExportKolommen.jsx, ../../components/beheer/Gronden/Gronden.jsx, ../../components/beheer/Lijsten/Lijsten.jsx, ../../components/beheer/MaakAccount/MaakAccount.jsx, ../../components/beheer/TonicEntities/TonicEntities.jsx, ../../components/beheer/Zaken/Zaken.jsx, ./Beheer.module.css, react
EXPORTS: Beheer
----- CODE START -----
import React, { useCallback, useState } from "react";
import Gronden from "../../components/beheer/Gronden/Gronden.jsx";
import AppKleuren from "../../components/beheer/AppKleuren/AppKleuren.jsx";
import TonicEntities from "../../components/beheer/TonicEntities/TonicEntities.jsx";
import Lijsten from "../../components/beheer/Lijsten/Lijsten.jsx";
import CustomEntities from "../../components/beheer/CustomEntities/CustomEntities.jsx";
import ExportKolommen from "../../components/beheer/ExportKolommen/ExportKolommen.jsx";
import Zaken from "../../components/beheer/Zaken/Zaken.jsx";
import MaakAccount from "../../components/beheer/MaakAccount/MaakAccount.jsx";
import Audit from "../../components/beheer/Audit/Audit.jsx";
import styles from "./Beheer.module.css";

const TABS = [
    { id: "gronden", label: "Weigeringsgronden" },
    { id: "app", label: "App-kleuren" },
    { id: "tonic", label: "Tonic AI" },
    { id: "lijsten", label: "Whitelist & blacklist" },
    { id: "entities", label: "Custom entities" },
    { id: "exportkolommen", label: "Exportkolommen" },
    { id: "zaken", label: "Zaakinstellingen" },
    { id: "accounts", label: "Maak account" },
    { id: "audit", label: "Audit" },
];

export default function Beheer() {
    const [tab, setTab] = useState("gronden");
    const [melding, setMelding] = useState(null);

    const toon = useCallback((tekst) => {
        setMelding(tekst);
        setTimeout(() => setMelding(null), 3500);
    }, []);

    return (
        <div className={`${styles.inhoud} ${styles.root}`}>
            <header className={styles.paginakop}>
                <h1>Beheer</h1>
                <span className={styles.lakstreep} aria-hidden="true" />
                <p className={styles.toelichting}>
                    Instelbare weigeringsgronden, geautomatiseerde laklijsten en
                    verwerkingsinstellingen per zaak. Elke wijziging wordt vastgelegd in de
                    audittrail.
                </p>
            </header>

            <nav className={styles.beheertabs} role="tablist">
                {TABS.map((t) => (
                    <button
                        key={t.id}
                        role="tab"
                        aria-selected={tab === t.id}
                        className={tab === t.id ? styles.actiefTab : ""}
                        onClick={() => setTab(t.id)}
                    >
                        {t.label}
                    </button>
                ))}
            </nav>

            {tab === "gronden" && <Gronden toon={toon} />}
            {tab === "app" && <AppKleuren toon={toon} />}
            {tab === "tonic" && <TonicEntities toon={toon} />}
            {tab === "lijsten" && <Lijsten toon={toon} />}
            {tab === "entities" && <CustomEntities toon={toon} />}
            {tab === "exportkolommen" && <ExportKolommen toon={toon} />}
            {tab === "zaken" && <Zaken toon={toon} />}
            {tab === "accounts" && <MaakAccount toon={toon} />}
            {tab === "audit" && <Audit />}

            {melding && (
                <div className={styles.melding} role="status">
                    {melding}
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Beoordelen/Beoordelen.jsx =====
META: lines=565 bytes=23230
IMPORTS: ../../api.js, ../../components/beoordelen/RechterpaneelGronden/RechterpaneelGronden.jsx, ../../components/beoordelen/RechterpaneelSnapshots/RechterpaneelSnapshots.jsx, ../../components/beoordelen/RechterpaneelTags/RechterpaneelTags.jsx, ../../components/beoordelen/Statistieken/Statistieken.jsx, ../../components/beoordelen/useBeoordelenData.js, ../../components/beoordelen/useDocumentBuren.js, ../../components/beoordelen/useDocumentSneltoetsen.js, ../../components/beoordelen/useVerlatenGuard.js, ../../components/beoordelen/VerlatenPopup/VerlatenPopup.jsx, ../../components/beoordelen/VersiesPopup/VersiesPopup.jsx, ../../components/beoordelen/ViewerVlak/ViewerVlak.jsx, ../../components/beoordelen/WerkBalk/WerkBalk.jsx, ../../viewer/useWebViewer.js, ../../viewer/useWebViewerAnnotations.js, ../../viewer/useWebViewerStatistieken.js, ./Beoordelen.module.css, react, react-router-dom
EXPORTS: Beoordelen
SYMBOLS: const-fn:herstelGekozenVersieAlsWerkkopie, const-fn:herstelSnapshot, const-fn:kiesGrond, const-fn:maakDefinitief, const-fn:maakSnapshot, const-fn:previewModus, const-fn:wijzigTags, fn:isAlleenLezenFase, fn:viewerModeForVersion
----- CODE START -----
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../../api.js";
import { useWebViewer } from "../../viewer/useWebViewer.js";
import { useBeoordelenData } from "../../components/beoordelen/useBeoordelenData.js";
import WerkBalk from "../../components/beoordelen/WerkBalk/WerkBalk.jsx";
import Statistieken from "../../components/beoordelen/Statistieken/Statistieken.jsx";
import ViewerVlak from "../../components/beoordelen/ViewerVlak/ViewerVlak.jsx";
import RechterpaneelGronden from "../../components/beoordelen/RechterpaneelGronden/RechterpaneelGronden.jsx";
import RechterpaneelTags from "../../components/beoordelen/RechterpaneelTags/RechterpaneelTags.jsx";
import RechterpaneelSnapshots from "../../components/beoordelen/RechterpaneelSnapshots/RechterpaneelSnapshots.jsx";
import VersiesPopup from "../../components/beoordelen/VersiesPopup/VersiesPopup.jsx";
import VerlatenPopup from "../../components/beoordelen/VerlatenPopup/VerlatenPopup.jsx";
import { useVerlatenGuard } from "../../components/beoordelen/useVerlatenGuard.js";
import { useDocumentBuren } from "../../components/beoordelen/useDocumentBuren.js";
import { useDocumentSneltoetsen } from "../../components/beoordelen/useDocumentSneltoetsen.js";
import { OPSLAAN_REDENEN } from "../../viewer/useWebViewerAnnotations.js";
import { useWebViewerStatistieken } from "../../viewer/useWebViewerStatistieken.js";
import styles from "./Beoordelen.module.css";

const APRYSE_PHASES = new Set(["geconverteerd", "ai_gesuggereerd", "in_beoordeling"]);

// De gelakte PDF tonen we wel in de viewer, maar uitsluitend om te lezen: de
// tekst is eruit weg, dus annotaties zouden nergens meer op aansluiten.
const ALLEEN_LEZEN_PHASES = new Set(["gelakt"]);

function isAlleenLezenFase(version) {
    return ALLEEN_LEZEN_PHASES.has(String(version?.fase || "").trim());
}

function viewerModeForVersion(version, fallbackMode = "native") {
    if (!version) return fallbackMode;
    const fase = String(version.fase || "").trim();
    return APRYSE_PHASES.has(fase) || ALLEEN_LEZEN_PHASES.has(fase) ? "apryse" : "native";
}

export default function Beoordelen({ onOpenMenu }) {
    const { caseId, docId } = useParams();
    const navigate = useNavigate();
    const [zoekParams] = useSearchParams();
    const houderRef = useRef(null);

    // Filters/sortering van de documentenlijst reizen mee in de URL, zodat
    // "volgende" hetzelfde betekent als in de tabel waar je vandaan komt.
    const lijstQuery = useMemo(() => {
        const params = new URLSearchParams();
        for (const sleutel of ["q", "fase", "tag", "sort", "dir"]) {
            const waarde = zoekParams.get(sleutel);
            if (waarde) params.set(sleutel, waarde);
        }
        return params.toString();
    }, [zoekParams]);

    const [actieveGrond, setActieveGrond] = useState(null);
    const [tagInvoer, setTagInvoer] = useState("");
    const [melding, setMelding] = useState(null);
    const [gekozenVersieKey, setGekozenVersieKey] = useState(null);
    const [werkkopieVersieKey, setWerkkopieVersieKey] = useState(null);
    const werkkopieInitRef = useRef(null);
    const [rawReasonByGround, setRawReasonByGround] = useState({});
    const [toonVersies, setToonVersies] = useState(false);
    const [bezig, setBezig] = useState(null);

    const {
        gronden,
        snapshots,
        docTags,
        currentUserName,
        versies,
        huidigeVersieKey,
        werkkopieStatus,
        viewerMeta,
        voegSnapshotToe,
        herlaadTags,
    } = useBeoordelenData(docId, caseId);

    // Versie waarop bewerken zinvol is: die heeft nog tekst en de annotaties uit
    // de werkkopie sluiten erop aan. Volgorde is een voorkeur, niet meer dan dat.
    const bewerkVersie =
        ["in_beoordeling", "ai_gesuggereerd", "geconverteerd"]
            .map((fase) => versies.find((v) => String(v.fase || "").trim() === fase))
            .find(Boolean) || null;

    // Na een export is de "huidige" versie de export-zip, en dat is een bundel
    // van de hele zaak in plaats van dit document. Openen we het document, dan
    // willen we de gelakte PDF zien.
    const gelakteVersie = versies.find((v) => isAlleenLezenFase(v)) || null;
    const huidigeVersie = versies.find((v) => v.key === huidigeVersieKey) || null;
    const standaardVersieKey =
        gelakteVersie && String(huidigeVersie?.fase || "").trim() === "geexporteerd"
            ? gelakteVersie.key
            : huidigeVersieKey;

    const effectiveVersieKey = gekozenVersieKey ?? werkkopieVersieKey ?? standaardVersieKey;
    const openstaandWerk = Boolean(werkkopieStatus?.nieuwer_dan_lakken);
    const gekozenVersie = versies.find((v) => v.key === effectiveVersieKey) || null;

    const historischeVersie = Boolean(
        gekozenVersie && huidigeVersieKey && gekozenVersie.key !== huidigeVersieKey
    );

    const werkkopieActief = Boolean(
        werkkopieVersieKey && gekozenVersie && werkkopieVersieKey === gekozenVersie.key
    );

    // Zijn er wijzigingen in de werkkopie van na de laatste lakronde, dan openen
    // we op de bewerkbare versie: anders kijkt de beoordelaar naar de oude
    // gelakte PDF en lijkt zijn werk weg.
    useEffect(() => {
        if (!openstaandWerk || !bewerkVersie) return;
        if (werkkopieInitRef.current === docId) return;
        werkkopieInitRef.current = docId;
        setWerkkopieVersieKey(bewerkVersie.key);
    }, [docId, openstaandWerk, bewerkVersie]);

    // De gelakte versie is altijd alleen-lezen, ook als het de huidige versie is.
    const gelaktInBeeld = isAlleenLezenFase(gekozenVersie);
    const previewModus = (historischeVersie && !werkkopieActief) || gelaktInBeeld;

    const contentUrl = historischeVersie
        ? `/api/documenten/${docId}/content?targetDocumentId=${encodeURIComponent(
              gekozenVersie.document_id
          )}&version=${encodeURIComponent(gekozenVersie.version)}`
        : `/api/documenten/${docId}/content`;
    const selectedViewerMode = viewerModeForVersion(gekozenVersie, viewerMeta.mode);
    const webviewerZichtbaar = !viewerMeta.laden && selectedViewerMode === "apryse";
    const webviewerNodig =
        !viewerMeta.laden &&
        (viewerMeta.mode === "apryse" ||
            versies.some(
                (v) =>
                    APRYSE_PHASES.has(String(v.fase || "").trim()) || isAlleenLezenFase(v)
            ));

    const apryseActief = webviewerZichtbaar && !previewModus;

    const debugVersions =
        window.__LAK_DEBUG_VERSIONS__ === true ||
        window.localStorage.getItem("lak.debug.versions") === "1";

    const logVersions = useCallback(
        (event, payload) => {
            if (!debugVersions) return;
            try {
                console.log(`[version-debug] ${event}`, payload);
            } catch {
                console.log(`[version-debug] ${event}`);
            }
        },
        [debugVersions]
    );

    const {
        status,
        foutmelding,
        wijzigingen,
        vuil,
        pasGrondToe,
        forceerOpslaan,
        verwerpWijzigingen,
        laadXfdf,
        instanceRef,
    } = useWebViewer({
            houderRef,
            docId,
            contentUrl,
            enabled: webviewerNodig,
            annotationUserName: currentUserName,
            readOnly: previewModus,
            // Geen annotatielaag op de gelakte PDF: die mag niet bewerkt en al
            // helemaal niet teruggeschreven worden naar de werkkopie.
            loadAnnotations: selectedViewerMode === "apryse" && !gelaktInBeeld,
        });

    const statistieken = useWebViewerStatistieken({
        instanceRef,
        enabled: webviewerZichtbaar && selectedViewerMode === "apryse" && !gelaktInBeeld,
        status,
        docId,
        contentUrl,
    });

    useEffect(() => {
        logVersions("state", {
            caseId,
            docId,
            huidigeVersieKey,
            gekozenVersieKey,
            werkkopieVersieKey,
            effectiveVersieKey,
            gekozenVersie: gekozenVersie
                ? {
                      key: gekozenVersie.key,
                      fase: gekozenVersie.fase,
                      document_id: gekozenVersie.document_id,
                      version: gekozenVersie.version,
                  }
                : null,
            previewModus,
            historischeVersie,
            werkkopieActief,
            contentUrl,
            viewerMode: selectedViewerMode,
            webviewerNodig,
            webviewerZichtbaar,
            apryseActief,
            status,
        });
    }, [
        logVersions,
        caseId,
        docId,
        huidigeVersieKey,
        gekozenVersieKey,
        werkkopieVersieKey,
        effectiveVersieKey,
        gekozenVersie,
        previewModus,
        historischeVersie,
        werkkopieActief,
        contentUrl,
        selectedViewerMode,
        webviewerNodig,
        webviewerZichtbaar,
        apryseActief,
        status,
    ]);

    useEffect(() => {
        let actief = true;
        api.beheer
            .tonicEntityMappings()
            .then((rows) => {
                if (!actief) return;
                const map = {};
                for (const row of Array.isArray(rows) ? rows : []) {
                    const grond = String(row?.rule || "").trim();
                    const rawReason = String(row?.label || "").trim();
                    if (!grond || !rawReason) continue;
                    if (!map[grond]) map[grond] = rawReason;
                }
                setRawReasonByGround(map);
            })
            .catch(() => {
                if (!actief) return;
                setRawReasonByGround({});
            });
        return () => {
            actief = false;
        };
    }, []);

    const toon = useCallback((tekst) => {
        setMelding(tekst);
        setTimeout(() => setMelding(null), 3500);
    }, []);

    // Legt alles wat in de viewer staat vast in de werkkopie en gooit een
    // duidelijke fout als dat om welke reden dan ook niet gelukt is.
    const vastleggenInWerkkopie = useCallback(async () => {
        const resultaat = await forceerOpslaan();
        if (!resultaat?.opgeslagen) {
            const uitleg = OPSLAAN_REDENEN[resultaat?.reden] || resultaat?.reden || "onbekende oorzaak";
            throw new Error(`Wijzigingen konden niet in de werkkopie worden vastgelegd: ${uitleg}.`);
        }
        return resultaat;
    }, [forceerOpslaan]);

    const buren = useDocumentBuren(caseId, docId, lijstQuery);

    // Doorbladeren is een bewuste stap in de beoordeling: we leggen eerst vast
    // en navigeren daarna, zodat de verlaten-vraag hier niet in de weg zit.
    const gaNaarDocument = useCallback(
        async (doel) => {
            if (!doel?.document_id || bezig) return;
            setBezig("navigeren");
            try {
                if (vuil && !previewModus) await vastleggenInWerkkopie();
                navigate(
                    `/zaken/${caseId}/documenten/${doel.document_id}` +
                        (lijstQuery ? `?${lijstQuery}` : "")
                );
            } catch (e) {
                toon(`Wisselen van document afgebroken: ${e.message}`);
            } finally {
                setBezig(null);
            }
        },
        [bezig, vuil, previewModus, vastleggenInWerkkopie, navigate, caseId, lijstQuery, toon]
    );

    const gaVorige = useCallback(() => gaNaarDocument(buren.vorige), [gaNaarDocument, buren.vorige]);
    const gaVolgende = useCallback(
        () => gaNaarDocument(buren.volgende),
        [gaNaarDocument, buren.volgende]
    );

    useDocumentSneltoetsen({
        houderRef,
        actief: !bezig,
        viewerKlaar: status === "klaar",
        onVorige: gaVorige,
        onVolgende: gaVolgende,
    });

    const verlaten = useVerlatenGuard({
        actief: vuil && !previewModus && !bezig,
        onVastleggen: vastleggenInWerkkopie,
        onNegeren: verwerpWijzigingen,
    });

    const kiesGrond = (grond) => {
        if (!apryseActief) return;
        setActieveGrond(grond.code);

        const rawReason =
            rawReasonByGround[String(grond.code || "").trim()] ||
            rawReasonByGround[String(grond.articleNumber || "").trim()] ||
            "NAME_GIVEN";

        const { toegepast } = pasGrondToe(grond, rawReason);

        toon(
            toegepast > 0
                ? `Grond ${grond.code} toegepast op ${toegepast} lakannotatie(s).`
                : `Grond ${grond.code} is nu de standaard voor nieuw lakwerk.`
        );
    };

    const wijzigTags = async (voegToe, verwijder) => {
        try {
            await api.muteerTags(caseId, [docId], voegToe, verwijder);
            await herlaadTags();
            setTagInvoer("");
            toon("Tags bijgewerkt.");
        } catch (e) {
            toon(e.message);
        }
    };

    const maakSnapshot = async () => {
        if (!apryseActief || bezig) return;
        setBezig("snapshot");
        try {
            await vastleggenInWerkkopie();
            const snap = await api.maakSnapshot(docId);
            voegSnapshotToe(snap);
            toon("Snapshot vastgelegd.");
        } catch (e) {
            toon(`Snapshot mislukt: ${e.message}`);
        } finally {
            setBezig(null);
        }
    };

    const herstelSnapshot = async (snap) => {
        logVersions("restore:start", { snapshot_id: snap?.snapshot_id });
        const { xfdf } = await api.herstelSnapshot(docId, snap.snapshot_id);
        await laadXfdf(xfdf);
        setGekozenVersieKey(null);
        setWerkkopieVersieKey(null);
        setToonVersies(false);
        logVersions("restore:done", {
            snapshot_id: snap?.snapshot_id,
            xfdfLength: xfdf?.length || 0,
        });
        toon("Snapshot teruggezet als werkkopie (historie blijft bewaard).");
    };

    const herstelGekozenVersieAlsWerkkopie = () => {
        if (!gekozenVersie) return;
        if (!historischeVersie) return;
        if (!APRYSE_PHASES.has(String(gekozenVersie.fase || "").trim())) return;

        setWerkkopieVersieKey(gekozenVersie.key);
        setGekozenVersieKey(gekozenVersie.key);
        setToonVersies(false);

        logVersions("restore-version:as-workcopy", {
            key: gekozenVersie.key,
            fase: gekozenVersie.fase,
            version: gekozenVersie.version,
        });

        toon("Geselecteerde versie teruggezet als werkkopie. Je kunt nu weer aanpassen.");
    };

    const maakDefinitief = async () => {
        if (!apryseActief || bezig) return;
        const bevestigd = window.confirm(
            gelakteVersie
                ? "Opnieuw definitief lakken? De geconverteerde versie wordt teruggezet, de " +
                  "annotaties uit de werkkopie worden daar opnieuw op toegepast en de vorige " +
                  "gelakte versie wordt vervangen. Een al gemaakte export blijft zoals hij is."
                : "Lakwerk definitief maken? Alle wijzigingen die je nu in de viewer ziet worden eerst " +
                "in de werkkopie vastgelegd en daarna door DRS permanent toegepast op een nieuwe " +
                "documentversie. Dit is per versie onomkeerbaar."
        );
        if (!bevestigd) return;

        setBezig("definitief");
        try {
            // Eerst vastleggen. Lukt dit niet, dan stoppen we: anders lakt DRS
            // een verouderde werkkopie definitief in.
            await vastleggenInWerkkopie();
            await api.definitief(docId, "Beoordeling afgerond");
            setGekozenVersieKey(null);
            setWerkkopieVersieKey(null);
            toon("Alle wijzigingen vastgelegd. REDACT_DOCUMENT in de wachtrij gezet.");
        } catch (e) {
            toon(`Definitief maken afgebroken: ${e.message}`);
        } finally {
            setBezig(null);
        }
    };

    const versieLabel =
        versies.find((v) => v.key === effectiveVersieKey)?.label ||
        (previewModus ? "Historische versie" : "Huidige versie");

    return (
        <div className={`${styles.hoofd} ${styles.root}`}>
            <div className={`${styles.beoordeel} ${styles.beoordeel3kolom} ${styles.grid}`}>
                <aside className={styles.linkerpaneel}>
                    <WerkBalk
                        caseId={caseId}
                        lijstQuery={lijstQuery}
                        buren={buren}
                        onVorigDocument={gaVorige}
                        onVolgendDocument={gaVolgende}
                        navigatieBezig={Boolean(bezig)}
                        viewerMeta={viewerMeta}
                        versieLabel={versieLabel}
                        previewModus={previewModus}
                        apryseActief={apryseActief}
                        webviewerZichtbaar={webviewerZichtbaar}
                        wijzigingen={wijzigingen}
                        werkkopieActief={werkkopieActief}
                        onTerugNaarHuidig={() => {
                            setGekozenVersieKey(null);
                            setWerkkopieVersieKey(null);
                        }}
                        onOpenMenu={onOpenMenu}
                    />
                    {openstaandWerk && !gelaktInBeeld && gelakteVersie && (
                        <p className={styles.gelaktMelding}>
                            Dit document is al definitief gelakt, maar er staan latere wijzigingen
                            in de werkkopie. Je bewerkt nu die werkkopie; klik op{" "}
                            <strong>Opnieuw definitief lakken</strong> om ze door te voeren.
                        </p>
                    )}
                    {gelaktInBeeld && (
                        <p className={styles.gelaktMelding}>
                            Dit is de definitief gelakte versie; die is alleen te lezen. Wil je nog
                            iets wijzigen, kies dan bij <strong>Documentversies</strong> de versie{" "}
                            <strong>{bewerkVersie?.label || "In beoordeling"}</strong> en herstel
                            die als werkkopie. Daarna kun je opnieuw definitief lakken.
                        </p>
                    )}
                    <Statistieken statistieken={statistieken} />
                </aside>

                <ViewerVlak
                    houderRef={houderRef}
                    viewerMeta={viewerMeta}
                    webviewerNodig={webviewerNodig}
                    webviewerZichtbaar={webviewerZichtbaar}
                    status={status}
                    foutmelding={foutmelding}
                    contentUrl={contentUrl}
                    docId={docId}
                />

                <aside className={styles.rechterpaneel}>
                    <section className={styles.paneelsectie}>
                        <h3>Acties</h3>
                        <div className={styles.actionGrid}>
                            <button
                                className={styles.kleinSecundair}
                                onClick={() => setToonVersies(true)}
                            >
                                Documentversies
                            </button>
                            <button
                                className={styles.kleinSecundair}
                                onClick={maakSnapshot}
                                disabled={!apryseActief || status !== "klaar" || Boolean(bezig)}
                            >
                                Snapshot vastleggen
                            </button>
                            <button
                                className={styles.kleinLak}
                                onClick={maakDefinitief}
                                disabled={!apryseActief || status !== "klaar" || Boolean(bezig)}
                            >
                                {bezig === "definitief"
                                    ? "Bezig…"
                                    : gelakteVersie
                                      ? "Opnieuw definitief lakken"
                                      : "Lakwerk definitief maken"}
                            </button>
                        </div>
                    </section>

                    <RechterpaneelGronden
                        gronden={gronden}
                        actieveGrond={actieveGrond}
                        apryseActief={apryseActief}
                        onKiesGrond={kiesGrond}
                    />
                    <RechterpaneelTags
                        docTags={docTags}
                        tagInvoer={tagInvoer}
                        onTagInvoerChange={setTagInvoer}
                        onWijzigTags={wijzigTags}
                    />
                    <RechterpaneelSnapshots
                        snapshots={snapshots}
                        apryseActief={webviewerZichtbaar}
                        onHerstel={herstelSnapshot}
                    />
                </aside>
            </div>

            <VersiesPopup
                open={toonVersies}
                onClose={() => setToonVersies(false)}
                versies={versies}
                effectieveVersieKey={effectiveVersieKey}
                huidigeVersieKey={huidigeVersieKey}
                werkkopieVersieKey={werkkopieVersieKey}
                onKiesVersie={(key) => {
                    logVersions("select-version", { key });
                    setGekozenVersieKey(key);
                    if (werkkopieVersieKey && werkkopieVersieKey !== key) {
                        setWerkkopieVersieKey(null);
                    }
                }}
                onHerstelVersie={herstelGekozenVersieAlsWerkkopie}
                kanHerstelVersie={Boolean(
                    gekozenVersie &&
                    historischeVersie &&
                    APRYSE_PHASES.has(String(gekozenVersie.fase || "").trim())
                )}
                snapshots={snapshots}
                apryseActief={webviewerZichtbaar}
                onHerstelSnapshot={herstelSnapshot}
            />

            <VerlatenPopup
                open={verlaten.open}
                bezig={verlaten.bezig}
                fout={verlaten.fout}
                onKeuze={verlaten.afhandelen}
            />

            {melding && (
                <div className={styles.melding} role="status">
                    {melding}
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Documenten/Documenten.jsx =====
META: lines=455 bytes=18948
IMPORTS: ../../api.js, ../../components/documenten/DocumentenTabel/DocumentenTabel.jsx, ../../components/documenten/DocumentenWerkbalk/DocumentenWerkbalk.jsx, ../../components/documenten/Paginering/Paginering.jsx, ../../components/documenten/useDocumentenData.js, ../../components/documenten/ZaakStatus/ZaakStatus.jsx, ./Documenten.module.css, react, react-router-dom
EXPORTS: Documenten
SYMBOLS: const-fn:actiefFilter, const-fn:allesOpPagina, const-fn:batch, const-fn:bewaarBewerking, const-fn:sorteer, const-fn:uploadBestanden, const-fn:verplaatsSelectie, const-fn:verwijderSelectie, const-fn:wijzigTags, const-fn:wisselSelectie, const-fn:zoek
----- CODE START -----
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { api } from "../../api.js";
import { useDocumentenData } from "../../components/documenten/useDocumentenData.js";
import DocumentenWerkbalk from "../../components/documenten/DocumentenWerkbalk/DocumentenWerkbalk.jsx";
import ZaakStatus from "../../components/documenten/ZaakStatus/ZaakStatus.jsx";
import DocumentenTabel from "../../components/documenten/DocumentenTabel/DocumentenTabel.jsx";
import Paginering from "../../components/documenten/Paginering/Paginering.jsx";
import styles from "./Documenten.module.css";

export default function Documenten() {
    const { caseId } = useParams();
    const [melding, setMelding] = useState(null);

    const [page, setPage] = useState(1);
    const [pageSize, setPageSize] = useState(50);
    const [q, setQ] = useState("");
    const [fase, setFase] = useState("");
    const [tag, setTag] = useState("");
    const [tagInvoer, setTagInvoer] = useState("");
    const [sort, setSort] = useState("volg_id");
    const [dir, setDir] = useState("asc");

    const [selectie, setSelectie] = useState(new Set());
    const [filterSelectie, setFilterSelectie] = useState(false);
    const [bewerkt, setBewerkt] = useState(null);
    const [concept, setConcept] = useState({});
    const [bezigMetUpload, setBezigMetUpload] = useState(false);
    const [bezigMetVerwijderen, setBezigMetVerwijderen] = useState(false);
    const [bezigMetVerplaatsen, setBezigMetVerplaatsen] = useState(false);
    const [statusVersie, setStatusVersie] = useState(0);

    const zoekTimer = useRef(null);

    const { zaak, data, fout, tagOverzicht, laad, laadZaak, laadTags } = useDocumentenData(caseId, {
        page,
        pageSize,
        q,
        fase,
        tag,
        sort,
        dir,
    });

    const prikStatus = useCallback(() => setStatusVersie((v) => v + 1), []);
    const toon = useCallback((tekst) => {
        setMelding(tekst);
        setTimeout(() => setMelding(null), 3500);
    }, []);

    const selectieAantal = filterSelectie ? (data?.total ?? 0) : selectie.size;
    const actiefFilter = () => ({ q, fase, tag });
    const totaalPaginas = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
    const fasen = useMemo(() => data?.fasen || [], [data]);

    useEffect(() => {
        setSelectie(new Set());
        setFilterSelectie(false);
    }, [page, q, fase, tag, caseId]);

    // Reist mee naar het beoordeelscherm zodat vorige/volgende daar dezelfde
    // volgorde en filters aanhoudt als deze tabel.
    const lijstQuery = useMemo(() => {
        const params = new URLSearchParams();
        if (q) params.set("q", q);
        if (fase) params.set("fase", fase);
        if (tag) params.set("tag", tag);
        if (sort) params.set("sort", sort);
        if (dir) params.set("dir", dir);
        return params.toString();
    }, [q, fase, tag, sort, dir]);

    const sorteer = (kolom) => {
        if (sort === kolom) setDir(dir === "asc" ? "desc" : "asc");
        else {
            setSort(kolom);
            setDir("asc");
        }
        setPage(1);
    };

    const zoek = (waarde) => {
        clearTimeout(zoekTimer.current);
        zoekTimer.current = setTimeout(() => {
            setQ(waarde);
            setPage(1);
        }, 300);
    };

    const wisselSelectie = (id) => {
        const kopie = new Set(selectie);
        kopie.has(id) ? kopie.delete(id) : kopie.add(id);
        setSelectie(kopie);
    };

    const allesOpPagina = () => {
        if (!data) return;
        const alles = data.items.every((d) => selectie.has(d.document_id));
        setSelectie(alles ? new Set() : new Set(data.items.map((d) => d.document_id)));
    };

    const batch = async (actie) => {
        // Opnieuw converteren zet het bronbestand terug als huidige versie en
        // gooit de vervolgfasen weg, inclusief de annotaties in de werkkopie:
        // die horen bij de vorige conversie en kloppen daarna niet meer.
        if (actie === "CONVERT_DOCUMENT") {
            const aantal = filterSelectie ? Number(data?.total || 0) : selectie.size;
            const bevestigd = window.confirm(
                `${aantal.toLocaleString("nl-NL")} document(en) opnieuw converteren vanaf het ` +
                    "bronbestand? Eerdere conversie-, AI-, lak- en exportresultaten tellen daarna " +
                    "niet meer mee en het lakwerk in de werkkopie gaat verloren."
            );
            if (!bevestigd) return;
        }

        try {
            const r = filterSelectie
                ? await api.batch(caseId, actie, null, actiefFilter())
                : await api.batch(caseId, actie, [...selectie]);
            const fallback = filterSelectie ? Number(data?.total || 0) : selectie.size;
            const aantal = Number(r?.job?.aantal ?? r?.aantal ?? r?.count ?? fallback);
            toon(
                `Batchactie "${actie}" in wachtrij gezet voor ${aantal.toLocaleString("nl-NL")} documenten.`
            );
            setSelectie(new Set());
            setFilterSelectie(false);
            laad();
            prikStatus();
        } catch (e) {
            toon(e.message);
        }
    };

    const uploadBestanden = async (bestanden) => {
        if (!bestanden?.length) return;
        setBezigMetUpload(true);
        try {
            const r = await api.uploads(caseId, [...bestanden]);
            let msg = `${r.toegevoegd.length} document(en) toegevoegd`;
            if (r.overgeslagen?.length)
                msg += `, ${r.overgeslagen.length} overgeslagen (al aanwezig)`;
            if (r.waarschuwing) msg += ` — ${r.waarschuwing}`;
            toon(msg + ".");
            laad();
            laadZaak();
            prikStatus();
        } catch (e) {
            toon(e.message);
        } finally {
            setBezigMetUpload(false);
        }
    };

    const wijzigTags = async (documentIds, voegToe, verwijder) => {
        try {
            const r =
                filterSelectie && documentIds === null
                    ? await api.muteerTags(caseId, [], voegToe, verwijder, actiefFilter())
                    : await api.muteerTags(caseId, documentIds, voegToe, verwijder);
            toon(`Tags bijgewerkt op ${r.geraakt} document(en).`);
            setTagInvoer("");
            laad();
            laadTags();
        } catch (e) {
            toon(e.message);
        }
    };

    const bewaarBewerking = async (doc) => {
        try {
            await api.metadata(doc.document_id, caseId, concept);
            setBewerkt(null);
            toon("Metadata opgeslagen (auditregel vastgelegd).");
            laad();
        } catch (e) {
            toon(e.message);
        }
    };

    const verwijderSelectie = async () => {
        if (filterSelectie) {
            toon("Verwijderen op volledige filterselectie wordt hier niet ondersteund.");
            return;
        }

        const ids = [...selectie];
        if (ids.length === 0) return;

        // Eerst opvragen wat er daadwerkelijk geraakt wordt: via de
        // afstammingsgraaf kunnen dat er meer zijn dan geselecteerd.
        let betrokken;
        try {
            const controles = await Promise.all(
                ids.map((documentId) => api.verwijderDocumentControle(documentId))
            );
            betrokken = new Set();
            for (const controle of controles) {
                (controle?.document_ids || []).forEach((id) => betrokken.add(id));
            }
        } catch (e) {
            toon(`Kon de gevolgen van het verwijderen niet bepalen: ${e.message}`);
            return;
        }

        const extra = Math.max(0, betrokken.size - ids.length);
        const bevestigd = window.confirm(
            `${ids.length.toLocaleString("nl-NL")} geselecteerd document(en) verwijderen?\n\n` +
                `Dit raakt in totaal ${betrokken.size.toLocaleString("nl-NL")} document(en)` +
                (extra > 0
                    ? `, waarvan ${extra.toLocaleString("nl-NL")} samenhangende variant(en) (bron, geconverteerd, gelakt).`
                    : ".") +
                `\n\nInclusief DB-records en bestanden op de PVC. Dit kan niet ongedaan worden gemaakt.`
        );
        if (!bevestigd) return;

        setBezigMetVerwijderen(true);
        let verwijderdeDocs = 0;
        let verwijderdeBestanden = 0;
        let mislukt = 0;

        try {
            for (const documentId of ids) {
                try {
                    const r = await api.verwijderDocument(documentId);
                    verwijderdeDocs += Array.isArray(r?.removed_document_ids)
                        ? r.removed_document_ids.length
                        : 1;
                    verwijderdeBestanden += Number(r?.removed_files || 0);
                } catch {
                    mislukt += 1;
                }
            }

            setSelectie(new Set());
            setFilterSelectie(false);
            await Promise.all([laad(), laadZaak(), laadTags()]);
            prikStatus();

            if (mislukt === 0) {
                toon(
                    `Verwijderen klaar (${verwijderdeDocs.toLocaleString("nl-NL")} document(en), ${verwijderdeBestanden.toLocaleString("nl-NL")} bestand(en)).`
                );
            } else {
                toon(
                    `Gedeeltelijk klaar: ${verwijderdeDocs.toLocaleString("nl-NL")} document(en) verwijderd, ${mislukt.toLocaleString("nl-NL")} mislukt.`
                );
            }
        } finally {
            setBezigMetVerwijderen(false);
        }
    };

    const verplaatsSelectie = async () => {
        if (filterSelectie) {
            toon("Verplaatsen op volledige filterselectie wordt hier niet ondersteund.");
            return;
        }

        const ids = [...selectie];
        if (ids.length === 0) return;

        let zaken = [];
        try {
            zaken = (await api.beheer.zaken()) || [];
        } catch (e) {
            toon(`Kon de zaken niet ophalen: ${e.message}`);
            return;
        }

        const andere = zaken.map((z) => z.case_id).filter((id) => id && id !== caseId);
        const doelZaak = window.prompt(
            `Naar welke zaak verplaatsen?\n\nBeschikbaar: ${andere.join(", ") || "(geen andere zaken)"}`,
            andere[0] || ""
        );
        if (!doelZaak || !doelZaak.trim()) return;

        setBezigMetVerplaatsen(true);
        try {
            const controle = await api.verplaatsDocumentenControle(ids, doelZaak.trim());
            const botsingen = controle?.volg_id_botsingen || [];

            const bevestigd = window.confirm(
                `${controle.aantal.toLocaleString("nl-NL")} document(en) verplaatsen ` +
                    `van "${controle.bron_zaak}" naar "${controle.doel_zaak}"?\n\n` +
                    `Inclusief samenhangende varianten (bron, geconverteerd, gelakt). ` +
                    `De export-zip blijft in de bronzaak.\n\n` +
                    (botsingen.length
                        ? `LET OP: ${botsingen.length} volg-id(s) bestaan al in de doelzaak: ` +
                          `${botsingen.slice(0, 10).join(", ")}${botsingen.length > 10 ? "…" : ""}\n\n`
                        : "") +
                    `Bestanden worden op de PVC verplaatst; dit is niet met een rollback terug te draaien.`
            );
            if (!bevestigd) return;

            const r = await api.verplaatsDocumenten(ids, doelZaak.trim());
            setSelectie(new Set());
            setFilterSelectie(false);
            await Promise.all([laad(), laadZaak(), laadTags()]);
            prikStatus();
            toon(
                `${Number(r?.verplaatst || 0).toLocaleString("nl-NL")} document(en) verplaatst naar ${r?.doel_zaak}.`
            );
        } catch (e) {
            toon(`Verplaatsen mislukt: ${e.message}`);
        } finally {
            setBezigMetVerplaatsen(false);
        }
    };

    return (
        <div className={`${styles.inhoud} ${styles.root}`}>
            <header className={styles.paginakop}>
                <h1>{caseId}</h1>
                <Link
                    className={styles.overzichtLink}
                    to={`/zaken/${encodeURIComponent(caseId)}/annotaties`}
                >
                    Annotatie overzicht
                </Link>
                <span className={styles.lakstreep} aria-hidden="true" />
                <p className={styles.toelichting}>
                    {zaak?.omschrijving} —{" "}
                    {zaak ? zaak.aantal_documenten.toLocaleString("nl-NL") : "…"} documenten. Zoeken
                    en filteren gebeurt op de server; selecteer documenten voor batchacties.
                </p>
            </header>

            <div className={`${styles.kaart} ${styles.card}`}>
                <ZaakStatus caseId={caseId} versie={statusVersie} />

                <DocumentenWerkbalk
                    fasen={fasen}
                    tagOverzicht={tagOverzicht}
                    q={q}
                    fase={fase}
                    tag={tag}
                    tagInvoer={tagInvoer}
                    selectieAantal={selectieAantal}
                    filterSelectie={filterSelectie}
                    bezigMetUpload={bezigMetUpload}
                    bezigMetVerwijderen={bezigMetVerwijderen}
                    onZoek={zoek}
                    onFaseChange={setFase}
                    onTagChange={setTag}
                    onTagInvoerChange={setTagInvoer}
                    onTagToevoegen={(t) =>
                        wijzigTags(filterSelectie ? null : [...selectie], [t], [])
                    }
                    onTagVerwijderen={(t) =>
                        wijzigTags(filterSelectie ? null : [...selectie], [], [t])
                    }
                    onUpload={uploadBestanden}
                    onVerwijderSelectie={verwijderSelectie}
                    bezigMetVerplaatsen={bezigMetVerplaatsen}
                    onVerplaatsSelectie={verplaatsSelectie}
                    onBatch={batch}
                    onSetPage={setPage}
                />

                {data &&
                    data.total > data.items.length &&
                    ((selectie.size === data.items.length && data.items.length > 0) ||
                        filterSelectie) && (
                        <div className={styles.selectiebalk}>
                            {filterSelectie ? (
                                <>
                                    Alle <strong>{data.total.toLocaleString("nl-NL")}</strong>{" "}
                                    documenten binnen het filter zijn geselecteerd.{" "}
                                    <a
                                        href="#"
                                        onClick={(e) => {
                                            e.preventDefault();
                                            setFilterSelectie(false);
                                            setSelectie(new Set());
                                        }}
                                    >
                                        Selectie wissen
                                    </a>
                                </>
                            ) : (
                                <>
                                    Alle {data.items.length} documenten op deze pagina zijn
                                    geselecteerd.{" "}
                                    <a
                                        href="#"
                                        onClick={(e) => {
                                            e.preventDefault();
                                            setFilterSelectie(true);
                                        }}
                                    >
                                        Selecteer alle {data.total.toLocaleString("nl-NL")}{" "}
                                        documenten binnen het filter
                                    </a>
                                </>
                            )}
                        </div>
                    )}

                {fout && <div className={styles.leeg}>Laden mislukt: {fout}</div>}
                {!data && !fout && <div className={styles.leeg}>Documenten laden…</div>}

                {data && (
                    <DocumentenTabel
                        caseId={caseId}
                        lijstQuery={lijstQuery}
                        items={data.items}
                        selectie={selectie}
                        bewerkt={bewerkt}
                        concept={concept}
                        tag={tag}
                        sort={sort}
                        dir={dir}
                        onWisselSelectie={wisselSelectie}
                        onAllesOpPagina={allesOpPagina}
                        onSorteer={sorteer}
                        onStartBewerking={(doc) => {
                            setBewerkt(doc.document_id);
                            setConcept({
                                naam: doc.naam,
                                document_datum: doc.document_datum,
                                opmerking: doc.opmerking,
                            });
                        }}
                        onBewaarBewerking={bewaarBewerking}
                        onAnnuleerBewerking={() => setBewerkt(null)}
                        onConceptChange={(delta) => setConcept((prev) => ({ ...prev, ...delta }))}
                        onTagFilter={(t) => {
                            setTag(tag === t ? "" : t);
                            setPage(1);
                        }}
                        onVerwijderTag={(docId, t) => wijzigTags([docId], [], [t])}
                    />
                )}

                {data && (
                    <Paginering
                        page={page}
                        pageSize={pageSize}
                        totaal={data.total}
                        totaalPaginas={totaalPaginas}
                        onPage={setPage}
                        onPageSize={setPageSize}
                    />
                )}
            </div>

            {melding && (
                <div className={styles.melding} role="status">
                    {melding}
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Inloggen/Inloggen.jsx =====
META: lines=80 bytes=3204
IMPORTS: ../../api.js, ./Inloggen.module.css, react
EXPORTS: Inloggen
SYMBOLS: const-fn:login
----- CODE START -----
import React, { useState } from "react";
import { api } from "../../api.js";
import styles from "./Inloggen.module.css";

/**
 * Inlogscherm: bij AUTH_MODE=lokaal een formulier, bij oidc één knop naar de
 * provider. De authConfig komt van de BFF zodat de frontend modus-agnostisch is.
 */
export default function Inloggen({ config, naAanmelden }) {
    const [gebruikersnaam, setGebruikersnaam] = useState("");
    const [wachtwoord, setWachtwoord] = useState("");
    const [fout, setFout] = useState(null);
    const [bezig, setBezig] = useState(false);

    const login = async (e) => {
        e.preventDefault();
        setBezig(true);
        setFout(null);
        try {
            await api.auth.login(gebruikersnaam, wachtwoord);
            await naAanmelden();
        } catch (err) {
            setFout(err.message);
        } finally {
            setBezig(false);
        }
    };

    return (
        <div className={styles.inlogvlak}>
            <div className={styles.inlogkaart}>
                <div className={`${styles.naam} ${styles.brand}`}>
                    Lak<span className={styles.gelaktDonker}>straat</span>straat
                </div>
                <p className={styles.subtitle}>Documentredactie · aanmelden vereist</p>

                {config.mode === "oidc" ? (
                    <a href="/api/auth/login" className={styles.fullWidthLink}>
                        <button className={styles.fullWidthButton}>
                            Inloggen via {config.provider || "OIDC"}
                        </button>
                    </a>
                ) : (
                    <form onSubmit={login} className={styles.form}>
                        <label className={styles.label}>
                            Gebruikersnaam
                            <input
                                type="text"
                                autoFocus
                                value={gebruikersnaam}
                                className={styles.input}
                                onChange={(e) => setGebruikersnaam(e.target.value)}
                                autoComplete="username"
                            />
                        </label>
                        <label className={styles.label}>
                            Wachtwoord
                            <input
                                type="password"
                                value={wachtwoord}
                                className={styles.input}
                                onChange={(e) => setWachtwoord(e.target.value)}
                                autoComplete="current-password"
                            />
                        </label>
                        <button
                            type="submit"
                            disabled={bezig || !gebruikersnaam || !wachtwoord}
                            className={styles.submitButton}
                        >
                            {bezig ? "Aanmelden…" : "Aanmelden"}
                        </button>
                    </form>
                )}
                {fout && <p className={styles.error}>{fout}</p>}
            </div>
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/pages/Zaken/Zaken.jsx =====
META: lines=107 bytes=4490
IMPORTS: ../../api.js, ../../components/zaken/FaseBalk/FaseBalk.jsx, ./Zaken.module.css, react, react-router-dom
EXPORTS: Zaken
SYMBOLS: const-fn:openNieuweZaak
----- CODE START -----
import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { api } from "../../api.js";
import FaseBalk from "../../components/zaken/FaseBalk/FaseBalk.jsx";
import styles from "./Zaken.module.css";

export default function Zaken() {
    const [zaken, setZaken] = useState(null);
    const [fout, setFout] = useState(null);
    const [nieuwZaaknummer, setNieuwZaaknummer] = useState("");
    const navigate = useNavigate();

    const openNieuweZaak = async () => {
        const nr = nieuwZaaknummer.trim();
        if (!/^[\w.\-]{1,80}$/.test(nr)) return;
        try {
            await api.maakZaak(nr);
            navigate(`/zaken/${nr}`);
        } catch (e) {
            setFout(e.message);
        }
    };

    useEffect(() => {
        api.zaken()
            .then(setZaken)
            .catch((e) => setFout(e.message));
    }, []);

    return (
        <div className={styles.inhoud}>
            <header className={styles.paginakop}>
                <h1>Zaken</h1>
                <span className={styles.lakstreep} aria-hidden="true" />
                <p className={styles.toelichting}>
                    Lopende verzoeken met voortgang per verwerkingsfase. Open een zaak voor de
                    documentenlijst, metadata en het lakwerk.
                </p>
                <div className={`${styles.taginvoer} ${styles.newCaseRow}`}>
                    <input
                        type="text"
                        placeholder="Nieuw zaaknummer (bv. WOO-2026-0201)…"
                        value={nieuwZaaknummer}
                        className={styles.newCaseInput}
                        onChange={(e) => setNieuwZaaknummer(e.target.value)}
                        onKeyDown={(e) => {
                            if (e.key === "Enter") openNieuweZaak();
                        }}
                        aria-label="Nieuw zaaknummer"
                    />
                    <button
                        className={`${styles.klein} ${styles.secundair}`}
                        disabled={!/^[\w.\-]{1,80}$/.test(nieuwZaaknummer.trim())}
                        onClick={openNieuweZaak}
                    >
                        Nieuwe zaak starten
                    </button>
                </div>
            </header>

            {fout && (
                <div className={styles.leeg}>
                    Zaken laden mislukt: {fout}. Controleer of de BFF draait.
                </div>
            )}
            {!zaken && !fout && <div className={styles.leeg}>Zaken laden…</div>}

            {zaken && (
                <div className={`${styles.kaart} ${styles.card}`}>
                    <table className={styles.gegevens}>
                        <thead>
                            <tr>
                                <th>Zaaknummer</th>
                                <th>Omschrijving</th>
                                <th className={styles.deadlineCol}>Deadline</th>
                                <th className={styles.documentsCol}>Documenten</th>
                                <th className={styles.progressCol}>Voortgang</th>
                            </tr>
                        </thead>
                        <tbody>
                            {zaken.map((z) => (
                                <tr key={z.case_id}>
                                    <td className={styles.mono}>
                                        <Link to={`/zaken/${z.case_id}`}>{z.case_id}</Link>
                                    </td>
                                    <td>{z.omschrijving}</td>
                                    <td className={styles.mono}>{z.deadline}</td>
                                    <td className={`${styles.mono} ${styles.docCount}`}>
                                        {z.aantal_documenten.toLocaleString("nl-NL")}
                                    </td>
                                    <td>
                                        <FaseBalk
                                            tellers={z.tellers}
                                            fasen={z.fasen}
                                            totaal={z.aantal_documenten}
                                        />
                                    </td>
                                </tr>
                            ))}
                        </tbody>
                    </table>
                </div>
            )}
        </div>
    );
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/laadWebViewer.js =====
META: lines=39 bytes=1542
EXPORTS: laadWebViewer, WEBVIEWER_LIB_PAD
SYMBOLS: fn:laadWebViewer
----- CODE START -----
// De Apryse WebViewer wordt NIET meegebundeld in de SPA, maar tijdens runtime
// geladen vanaf de WebViewer-pod (nginx die de library-assets serveert).
// Voordelen in dit landschap:
//   - de viewer-versie wordt centraal beheerd, versiegelijk met PDFNet in DRS;
//   - de SPA-bundel blijft klein en de WASM-assets komen van hun eigen pod;
//   - lokaal ontwikkelen zonder licentie/lib kan: de UI toont dan een placeholder.
//
// Routering (ingress of vite-proxy): /webviewer/lib/** -> WebViewer-pod.
// Lokaal alternatief: kopieer de map `webviewer/lib` uit het npm-pakket
// @pdftron/webviewer naar frontend/public/webviewer/lib.

const SCRIPT_PAD = "/webviewer/lib/webviewer.min.js";
const LIB_PAD = "/webviewer/lib";

let laadPromise = null;

export function laadWebViewer() {
    if (window.WebViewer) return Promise.resolve(window.WebViewer);
    if (laadPromise) return laadPromise;

    laadPromise = new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.src = SCRIPT_PAD;
        script.async = true;
        script.onload = () => {
            if (window.WebViewer) resolve(window.WebViewer);
            else reject(new Error("webviewer.min.js geladen maar window.WebViewer ontbreekt"));
        };
        script.onerror = () => {
            laadPromise = null;
            reject(new Error(`WebViewer-assets niet gevonden op ${SCRIPT_PAD}`));
        };
        document.head.appendChild(script);
    });
    return laadPromise;
}

export const WEBVIEWER_LIB_PAD = LIB_PAD;

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/useWebViewer.js =====
META: lines=118 bytes=3433
IMPORTS: ./useWebViewerAnnotations.js, ./useWebViewerDocument.js, ./useWebViewerInit.js, ./webviewerHelpers.js, react
EXPORTS: useWebViewer
SYMBOLS: fn:useWebViewer
----- CODE START -----
import { useRef, useState, useEffect } from 'react';
import { applyViewerIdentity } from './webviewerHelpers.js';
import { useWebViewerInit } from './useWebViewerInit.js';
import { useWebViewerDocument } from './useWebViewerDocument.js';
import { useWebViewerAnnotations } from './useWebViewerAnnotations.js';

export function useWebViewer({
    houderRef,
    docId,
    contentUrl,
    enabled = true,
    annotationUserName = '',
    readOnly = false,
    loadAnnotations = true,
}) {
    const instanceRef = useRef(null);
    const timerRef = useRef(null);
    const removeWatcherCleanupRef = useRef(() => {});
    const loadedContentUrlRef = useRef(null);
    const primaryUrlRef = useRef(null);
    // Annotatiestand zoals geladen bij het openen van het document (nulmeting).
    const baselineXfdfRef = useRef(null);

    const actieveGrondRef = useRef(null);
    const actieveRawReasonRef = useRef('');

    const contentUrlRef = useRef(contentUrl);
    contentUrlRef.current = contentUrl;
    const readOnlyRef = useRef(readOnly);
    readOnlyRef.current = readOnly;
    const loadAnnotationsRef = useRef(loadAnnotations);
    loadAnnotationsRef.current = loadAnnotations;

    const [status, setStatus] = useState('laden');
    const [foutmelding, setFoutmelding] = useState('');
    // wijzigingen = buffer nog niet weggeschreven (autosave zet dit weer op false)
    const [wijzigingen, setWijzigingen] = useState(false);
    // vuil = de gebruiker heeft in deze sessie iets gewijzigd en dat is nog niet
    // bewust vastgelegd of verworpen. Blijft staan ondanks de autosave.
    const [vuil, setVuil] = useState(false);

    useWebViewerInit({
        houderRef,
        docId,
        enabled,
        annotationUserName,
        contentUrlRef,
        readOnlyRef,
        loadAnnotationsRef,
        loadedContentUrlRef,
        primaryUrlRef,
        instanceRef,
        timerRef,
        removeWatcherCleanupRef,
        actieveGrondRef,
        actieveRawReasonRef,
        baselineXfdfRef,
        setStatus,
        setFoutmelding,
        setWijzigingen,
        setVuil,
    });

    useWebViewerDocument({
        contentUrl,
        enabled,
        docId,
        wijzigingen,
        loadAnnotations,
        readOnly,
        status,
        instanceRef,
        loadedContentUrlRef,
        primaryUrlRef,
        timerRef,
        readOnlyRef,
        loadAnnotationsRef,
        setStatus,
        setFoutmelding,
        setWijzigingen,
    });

    useEffect(() => {
        if (!enabled) return;
        const instance = instanceRef.current;
        if (!instance) return;
        applyViewerIdentity(instance, annotationUserName);
    }, [annotationUserName, enabled]);

    const { pasGrondToe, forceerOpslaan, verwerpWijzigingen, laadXfdf } = useWebViewerAnnotations({
        docId,
        houderRef,
        instanceRef,
        timerRef,
        loadedContentUrlRef,
        primaryUrlRef,
        readOnlyRef,
        loadAnnotationsRef,
        actieveGrondRef,
        actieveRawReasonRef,
        baselineXfdfRef,
        setWijzigingen,
        setVuil,
    });

    return {
        instanceRef,
        status,
        foutmelding,
        wijzigingen,
        vuil,
        pasGrondToe,
        forceerOpslaan,
        verwerpWijzigingen,
        laadXfdf,
    };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/useWebViewerAnnotations.js =====
META: lines=206 bytes=7701
IMPORTS: ../api.js, ./webviewerHelpers.js, react
EXPORTS: OPSLAAN_REDENEN, useWebViewerAnnotations
SYMBOLS: const-fn:lees, fn:useWebViewerAnnotations
----- CODE START -----
import { useCallback } from 'react';
import { api } from '../api.js';
import { removeMarkedRedactionControls } from './webviewerHelpers.js';

export const OPSLAAN_REDENEN = {
    'viewer-niet-geladen': 'de viewer is niet (meer) geladen',
    'alleen-lezen': 'het document staat in voorvertoning (alleen lezen)',
    'annotaties-uit': 'er worden voor deze versie geen annotaties beheerd',
    'andere-versie-geladen': 'er staat een andere documentversie in de viewer',
};

export function useWebViewerAnnotations({
    docId,
    houderRef,
    instanceRef,
    timerRef,
    loadedContentUrlRef,
    primaryUrlRef,
    readOnlyRef,
    loadAnnotationsRef,
    actieveGrondRef,
    actieveRawReasonRef,
    baselineXfdfRef,
    setWijzigingen,
    setVuil,
}) {
    const pasGrondToe = useCallback(
        (grond, rawReasonInput) => {
            const instance = instanceRef.current;
            if (!instance) return { toegepast: 0 };

            const { annotationManager, Annotations } = instance.Core;
            const selectie = annotationManager
                .getSelectedAnnotations()
                .filter((a) => a instanceof Annotations.RedactionAnnotation);

            actieveGrondRef.current = {
                code: grond.code,
                summary: grond.summary || '',
                description: grond.description || '',
            };

            const lees = (anno, key) => {
                try {
                    return String(anno.getCustomData?.(key) || '').trim();
                } catch {
                    return '';
                }
            };

            const mappedRawReason = String(rawReasonInput || '').trim();
            if (mappedRawReason) actieveRawReasonRef.current = mappedRawReason;

            const overlayText = grond.summary || `${grond.code} ${grond.description || ''}`.trim();

            for (const anno of selectie) {
                const rawReason =
                    mappedRawReason ||
                    lees(anno, 'dd_Raw_Reason') ||
                    lees(anno, 'dd_Annotation_Type') ||
                    lees(anno, 'rawReason') ||
                    'NAME_GIVEN';

                anno.OverlayText = overlayText;
                anno.setCustomData('refusalGround', grond.code);
                anno.setCustomData('dd_Refusal_Ground', grond.code);
                anno.setCustomData('dd_Raw_Reason', rawReason);
                anno.setCustomData('dd_Annotation_Type', rawReason);
                annotationManager.redrawAnnotation(anno);
            }

            if (selectie.length) {
                annotationManager.trigger('annotationChanged', [selectie, 'modify', {}]);
            }

            const redactionTool = instance.Core.documentViewer.getTool('AnnotationCreateRedaction');
            if (redactionTool?.setStyles) {
                redactionTool.setStyles({ OverlayText: overlayText });
            }

            removeMarkedRedactionControls(instance, houderRef.current);
            return { toegepast: selectie.length };
        },
        [houderRef, instanceRef, actieveGrondRef, actieveRawReasonRef],
    );

    /**
     * Legt de volledige actuele annotatiestand vast in de werkkopie.
     * Geeft altijd een resultaat terug ({ opgeslagen, reden }) zodat de aanroeper
     * kan zien of er echt is opgeslagen; stilzwijgend overslaan is hier fataal
     * (definitief lakken zou dan op een verouderde werkkopie draaien).
     */
    const forceerOpslaan = useCallback(async () => {
        const instance = instanceRef.current;
        if (!instance) return { opgeslagen: false, reden: 'viewer-niet-geladen' };
        if (readOnlyRef.current) return { opgeslagen: false, reden: 'alleen-lezen' };
        if (!loadAnnotationsRef.current) return { opgeslagen: false, reden: 'annotaties-uit' };
        if (loadedContentUrlRef.current !== primaryUrlRef.current) {
            return { opgeslagen: false, reden: 'andere-versie-geladen' };
        }

        const annotationManager = instance.Core.annotationManager;

        // Deselecteren commit openstaande bewerkingen (overlaytekst, notitie) op
        // de geselecteerde annotatie voordat we exporteren.
        try {
            annotationManager.deselectAllAnnotations?.();
        } catch {
            /* niet fataal */
        }

        clearTimeout(timerRef.current);

        const xfdf = await annotationManager.exportAnnotations();
        await api.bewaarAnnotaties(docId, xfdf);

        // Vanaf nu is dit de nieuwe nulmeting: eerder werk is bewust vastgelegd.
        if (baselineXfdfRef) baselineXfdfRef.current = xfdf;

        removeMarkedRedactionControls(instance, houderRef.current);
        setWijzigingen(false);
        setVuil?.(false);

        return { opgeslagen: true, reden: null, xfdf };
    }, [
        docId,
        houderRef,
        instanceRef,
        loadedContentUrlRef,
        primaryUrlRef,
        readOnlyRef,
        loadAnnotationsRef,
        baselineXfdfRef,
        timerRef,
        setWijzigingen,
        setVuil,
    ]);

    /**
     * Draait de werkkopie terug naar de stand van het moment waarop het document
     * in de viewer geladen werd. Nodig omdat de autosave tussentijds al naar de
     * werkkopie schrijft: "negeren" is daarom een expliciete terugzetactie.
     */
    const verwerpWijzigingen = useCallback(async () => {
        clearTimeout(timerRef.current);

        const baseline = baselineXfdfRef?.current;

        if (readOnlyRef.current || !loadAnnotationsRef.current) {
            setWijzigingen(false);
            setVuil?.(false);
            return { hersteld: false, reden: 'alleen-lezen' };
        }
        if (typeof baseline !== 'string') {
            setWijzigingen(false);
            setVuil?.(false);
            return { hersteld: false, reden: 'geen-nulmeting' };
        }

        await api.bewaarAnnotaties(docId, baseline);

        // Viewer weer gelijktrekken met de werkkopie (als hij nog leeft).
        const instance = instanceRef.current;
        if (instance) {
            try {
                const am = instance.Core.annotationManager;
                am.deleteAnnotations(am.getAnnotationsList(), { imported: true, force: true });
                await am.importAnnotations(baseline);
                removeMarkedRedactionControls(instance, houderRef.current);
            } catch {
                /* de werkkopie is leidend; viewer wordt sowieso opnieuw geladen */
            }
        }

        setWijzigingen(false);
        setVuil?.(false);
        return { hersteld: true, reden: null };
    }, [
        docId,
        houderRef,
        instanceRef,
        baselineXfdfRef,
        readOnlyRef,
        loadAnnotationsRef,
        timerRef,
        setWijzigingen,
        setVuil,
    ]);

    const laadXfdf = useCallback(
        async (xfdf) => {
            const instance = instanceRef.current;
            if (!instance) return;
            const am = instance.Core.annotationManager;
            am.deleteAnnotations(am.getAnnotationsList(), { imported: true, force: true });
            if (xfdf) await am.importAnnotations(xfdf);
            removeMarkedRedactionControls(instance, houderRef.current);
            if (baselineXfdfRef) baselineXfdfRef.current = typeof xfdf === 'string' ? xfdf : null;
            setVuil?.(false);
        },
        [houderRef, instanceRef, baselineXfdfRef, setVuil],
    );

    return { pasGrondToe, forceerOpslaan, verwerpWijzigingen, laadXfdf };
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/useWebViewerDocument.js =====
META: lines=111 bytes=3419
IMPORTS: ../api.js, react
EXPORTS: useWebViewerDocument
SYMBOLS: fn:useWebViewerDocument
----- CODE START -----
import { useEffect } from 'react';
import { api } from '../api.js';

export function useWebViewerDocument({
    contentUrl,
    enabled,
    docId,
    wijzigingen,
    loadAnnotations,
    readOnly,
    status,
    instanceRef,
    loadedContentUrlRef,
    primaryUrlRef,
    timerRef,
    readOnlyRef,
    loadAnnotationsRef,
    setStatus,
    setFoutmelding,
    setWijzigingen,
}) {
    useEffect(() => {
        if (!enabled) return;
        const instance = instanceRef.current;
        if (!instance || !contentUrl) return;
        if (loadedContentUrlRef.current === contentUrl) return;

        const debugVersions =
            window.__LAK_DEBUG_VERSIONS__ === true ||
            window.localStorage.getItem('lak.debug.versions') === '1';

        let cancelled = false;

        (async () => {
            try {
                if (debugVersions) {
                    console.log('[version-debug] webviewer:load:start', {
                        docId,
                        from: loadedContentUrlRef.current,
                        to: contentUrl,
                        primary: primaryUrlRef.current,
                        wijzigingen,
                    });
                }

                if (
                    loadedContentUrlRef.current === primaryUrlRef.current &&
                    !readOnlyRef.current &&
                    loadAnnotationsRef.current &&
                    wijzigingen
                ) {
                    clearTimeout(timerRef.current);
                    try {
                        const xfdf = await instance.Core.annotationManager.exportAnnotations();
                        await api.bewaarAnnotaties(docId, xfdf).catch(() => {});
                    } catch {}
                } else {
                    clearTimeout(timerRef.current);
                }

                if (cancelled) return;

                setWijzigingen(false);
                loadedContentUrlRef.current = contentUrl;
                setStatus('laden');
                setFoutmelding('');
                await instance.UI.loadDocument(contentUrl);

                if (debugVersions) {
                    console.log('[version-debug] webviewer:load:done', {
                        docId,
                        loaded: contentUrl,
                    });
                }
            } catch (err) {
                if (cancelled) return;
                loadedContentUrlRef.current = null;
                setFoutmelding(String(err?.message || err));
                setStatus('geen-lib');

                if (debugVersions) {
                    console.log('[version-debug] webviewer:load:error', {
                        docId,
                        contentUrl,
                        error: String(err?.message || err),
                    });
                }
            }
        })();

        return () => {
            cancelled = true;
        };
    }, [contentUrl, enabled, docId, wijzigingen]);

    useEffect(() => {
        if (!enabled || !loadAnnotations) return;
        primaryUrlRef.current = contentUrl;
    }, [contentUrl, loadAnnotations, enabled]);

    useEffect(() => {
        const am = instanceRef.current?.Core?.annotationManager;
        if (!am) return;
        if (readOnlyRef.current) {
            am.enableReadOnlyMode?.();
        } else {
            am.disableReadOnlyMode?.();
        }
    }, [status, readOnly]);
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/useWebViewerInit.js =====
META: lines=197 bytes=6600
IMPORTS: ../api.js, ./laadWebViewer.js, ./webViewerEventListeners.js, ./webviewerHelpers.js, react
EXPORTS: useWebViewerInit
SYMBOLS: const-fn:disposeInstance, fn:init, fn:useWebViewerInit
----- CODE START -----
import { useEffect } from 'react';
import { laadWebViewer, WEBVIEWER_LIB_PAD } from './laadWebViewer.js';
import { api } from '../api.js';
import {
    REDACTION_ONLY_DISABLED_ELEMENTS,
    removeMarkedRedactionControls,
    startRemoveMarkedRedactionControlsWatcher,
    startNotesPanelToggleCleanup,
    applyViewerIdentity,
    AUTOSAVE_MS,
} from './webviewerHelpers.js';
import { onDocumentLoad, onAnnotationChanged } from './webViewerEventListeners.js';

export function useWebViewerInit({
    houderRef,
    docId,
    enabled,
    annotationUserName,
    contentUrlRef,
    readOnlyRef,
    loadAnnotationsRef,
    loadedContentUrlRef,
    primaryUrlRef,
    instanceRef,
    timerRef,
    removeWatcherCleanupRef,
    actieveGrondRef,
    actieveRawReasonRef,
    baselineXfdfRef,
    setStatus,
    setFoutmelding,
    setWijzigingen,
    setVuil,
}) {
    useEffect(() => {
        let gestopt = false;

        const disposeInstance = () => {
            clearTimeout(timerRef.current);
            removeWatcherCleanupRef.current();
            removeWatcherCleanupRef.current = () => {};
            loadedContentUrlRef.current = null;
            primaryUrlRef.current = null;
            if (baselineXfdfRef) baselineXfdfRef.current = null;

            if (instanceRef.current) {
                instanceRef.current.UI.dispose();
                instanceRef.current = null;
            }
        };

        if (!enabled) {
            disposeInstance();
            setStatus('uit');
            setFoutmelding('');
            setWijzigingen(false);
            setVuil(false);
            return () => {};
        }

        async function init() {
            try {
                setStatus('laden');
                const WebViewer = await laadWebViewer();
                if (gestopt) return;

                const username = String(annotationUserName || '').trim();
                const initialUrl = contentUrlRef.current;

                primaryUrlRef.current = initialUrl;

                const instance = await WebViewer(
                    {
                        path: WEBVIEWER_LIB_PAD,
                        initialDoc: initialUrl,
                        licenseKey: window.__WEBVIEWER_LICENTIE__ || undefined,
                        enableRedaction: true,
                        enableAnnotations: true,
                        fullAPI: false,
                        css: '/webviewer-thema.css',
                        annotationUser: username || undefined,
                        annotationAdmin: username || undefined,
                        isAdminUser: true,
                    },
                    houderRef.current,
                );

                if (gestopt) {
                    instance.UI.dispose();
                    return;
                }

                instanceRef.current = instance;
                loadedContentUrlRef.current = initialUrl;

                removeWatcherCleanupRef.current();

                const watcherCleanup = startRemoveMarkedRedactionControlsWatcher(
                    instance,
                    houderRef.current,
                );
                const notesPanelCleanup = startNotesPanelToggleCleanup(instance, houderRef.current);

                removeWatcherCleanupRef.current = () => {
                    watcherCleanup();
                    notesPanelCleanup();
                };

                const { documentViewer, annotationManager } = instance.Core;
                applyViewerIdentity(instance, username);
                instance.UI.setLanguage('nl');
                instance.UI.enableFeatures([instance.UI.Feature.Redaction]);
                instance.UI.disableElements(REDACTION_ONLY_DISABLED_ELEMENTS);
                instance.UI.setToolbarGroup('toolbarGroup-Redact');
                removeMarkedRedactionControls(instance, houderRef.current);

                const handleDocumentLoad = onDocumentLoad({
                    isStopped: () => gestopt,
                    instance,
                    houderRef,
                    loadedContentUrlRef,
                    primaryUrlRef,
                    loadAnnotationsRef,
                    docId,
                    readOnlyRef,
                    setStatus,
                    api,
                    removeMarkedRedactionControls,
                    applyViewerIdentity,
                    annotationUserName: username,
                    baselineXfdfRef,
                    setVuil,
                });

                const handleAnnotationChanged = onAnnotationChanged({
                    instance,
                    houderRef,
                    readOnlyRef,
                    loadAnnotationsRef,
                    loadedContentUrlRef,
                    primaryUrlRef,
                    setWijzigingen,
                    timerRef,
                    AUTOSAVE_MS,
                    api,
                    docId,
                    removeMarkedRedactionControls,
                    annotationUserName: username,
                    actieveGrondRef,
                    actieveRawReasonRef,
                    setVuil,
                });

                documentViewer.addEventListener('documentLoaded', handleDocumentLoad);
                annotationManager.addEventListener('annotationChanged', handleAnnotationChanged);

                if (contentUrlRef.current !== initialUrl) {
                    const latestUrl = contentUrlRef.current;
                    loadedContentUrlRef.current = latestUrl;
                    setStatus('laden');
                    await instance.UI.loadDocument(latestUrl);
                }
            } catch (err) {
                setFoutmelding(String(err.message || err));
                setStatus('geen-lib');
            }
        }

        if (houderRef.current) init();

        return () => {
            gestopt = true;
            disposeInstance();
        };
    }, [
        actieveGrondRef,
        actieveRawReasonRef,
        annotationUserName,
        baselineXfdfRef,
        contentUrlRef,
        docId,
        enabled,
        houderRef,
        instanceRef,
        loadAnnotationsRef,
        loadedContentUrlRef,
        primaryUrlRef,
        readOnlyRef,
        removeWatcherCleanupRef,
        setFoutmelding,
        setStatus,
        setWijzigingen,
        setVuil,
        timerRef,
    ]);
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/useWebViewerStatistieken.js =====
META: lines=102 bytes=3378
IMPORTS: react
EXPORTS: LEGE_STATISTIEKEN, useWebViewerStatistieken
SYMBOLS: const-fn:lees, const-fn:opnieuw, fn:useWebViewerStatistieken
----- CODE START -----
import { useCallback, useEffect, useState } from 'react';

export const LEGE_STATISTIEKEN = {
    totaal: 0,
    ai: 0,
    handmatig: 0,
    paginas: 0,
    perGrond: [],
};

const lees = (anno, key) => {
    try {
        return String(anno.getCustomData?.(key) || '').trim();
    } catch {
        return '';
    }
};

/**
 * Telt de lakvlakken die op dit moment in de viewer staan.
 *
 * De cijfers komen bewust uit de annotatielaag en niet uit de database: de
 * beoordelaar wil zien wat er nu voor hem staat, inclusief wat hij zojuist zelf
 * heeft toegevoegd of verwijderd en nog niet is vastgelegd.
 */
export function useWebViewerStatistieken({ instanceRef, enabled, status, docId, contentUrl }) {
    const [statistieken, setStatistieken] = useState(LEGE_STATISTIEKEN);

    const bereken = useCallback(() => {
        const instance = instanceRef.current;
        if (!instance || !enabled) {
            setStatistieken(LEGE_STATISTIEKEN);
            return;
        }

        let lijst;
        try {
            const { annotationManager, Annotations } = instance.Core;
            lijst = annotationManager
                .getAnnotationsList()
                .filter((anno) => anno instanceof Annotations.RedactionAnnotation);
        } catch {
            setStatistieken(LEGE_STATISTIEKEN);
            return;
        }

        const perGrond = new Map();
        const paginas = new Set();
        let ai = 0;

        for (const anno of lijst) {
            const grond =
                lees(anno, 'dd_Refusal_Ground') || lees(anno, 'refusalGround') || 'Zonder grond';
            perGrond.set(grond, (perGrond.get(grond) || 0) + 1);

            if (anno.PageNumber) paginas.add(anno.PageNumber);

            const auteur = lees(anno, 'author') || lees(anno, 'dd_author');
            if (auteur.toUpperCase() === 'AI') ai++;
        }

        setStatistieken({
            totaal: lijst.length,
            ai,
            handmatig: lijst.length - ai,
            paginas: paginas.size,
            perGrond: [...perGrond.entries()]
                .map(([grond, aantal]) => ({ grond, aantal }))
                .sort((a, b) => b.aantal - a.aantal || a.grond.localeCompare(b.grond)),
        });
    }, [instanceRef, enabled]);

    useEffect(() => {
        const instance = instanceRef.current;
        if (!instance || !enabled) {
            setStatistieken(LEGE_STATISTIEKEN);
            return undefined;
        }

        let annotationManager;
        try {
            annotationManager = instance.Core.annotationManager;
        } catch {
            return undefined;
        }

        const opnieuw = () => bereken();
        annotationManager.addEventListener('annotationChanged', opnieuw);
        annotationManager.addEventListener('annotationsLoaded', opnieuw);
        opnieuw();

        return () => {
            annotationManager.removeEventListener('annotationChanged', opnieuw);
            annotationManager.removeEventListener('annotationsLoaded', opnieuw);
        };
        // status en contentUrl zitten erbij omdat de viewer-instance en de
        // annotatielaag pas na het laden van een document beschikbaar zijn.
    }, [bereken, enabled, instanceRef, status, docId, contentUrl]);

    return statistieken;
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/webViewerEventListeners.js =====
META: lines=317 bytes=11947
EXPORTS: onAnnotationChanged, onDocumentLoad
SYMBOLS: const-fn:inferRefusalGroundFromOverlay, const-fn:isRedaction, const-fn:lees, const-fn:zetMetadata, fn:onAnnotationChanged, fn:onDocumentLoad, fn:setCustomDataIfChanged
----- CODE START -----
export function onDocumentLoad({
    isStopped,
    instance,
    houderRef,
    loadedContentUrlRef,
    primaryUrlRef,
    loadAnnotationsRef,
    docId,
    readOnlyRef,
    setStatus,
    api,
    removeMarkedRedactionControls,
    applyViewerIdentity,
    annotationUserName,
    baselineXfdfRef,
    setVuil,
}) {
    return async function handleDocumentLoad() {
        if (isStopped()) return;

        const debugVersions =
            window.__LAK_DEBUG_VERSIONS__ === true ||
            window.localStorage.getItem('lak.debug.versions') === '1';
        const debugAnnots =
            window.__LAK_DEBUG_ANNOTS__ === true ||
            window.localStorage.getItem('lak.debug.annots') === '1';
        const debug = debugVersions || debugAnnots;

        const { annotationManager } = instance.Core;
        const shouldLoadAnnotations = Boolean(loadAnnotationsRef.current);
        const loadedUrl = String(loadedContentUrlRef.current || '');
        const primaryUrl = String(primaryUrlRef.current || '');
        const isPrimary = loadedUrl === primaryUrl;

        try {
            if (debug) {
                console.log('[annot-debug] document-load:start', {
                    docId,
                    loadedUrl,
                    primaryUrl,
                    isPrimary,
                    shouldLoadAnnotations,
                    readOnly: Boolean(readOnlyRef.current),
                });
            }

            applyViewerIdentity(instance, annotationUserName);

            annotationManager.deleteAnnotations(annotationManager.getAnnotationsList(), {
                imported: true,
                force: true,
            });

            if (shouldLoadAnnotations) {
                const haalAnnotatiesOp =
                    typeof api?.haalAnnotatiesOp === 'function'
                        ? api.haalAnnotatiesOp.bind(api)
                        : api?.annotaties?.bind(api);

                const payload = haalAnnotatiesOp ? await haalAnnotatiesOp(docId) : null;

                let xfdf = '';
                if (typeof payload === 'string') xfdf = payload;
                else if (typeof payload?.xfdf === 'string') xfdf = payload.xfdf;
                else if (typeof payload?.data?.xfdf === 'string') xfdf = payload.data.xfdf;

                if (debug) {
                    console.log('[annot-debug] document-load:xfdf-payload', {
                        docId,
                        payloadType: payload === null ? 'null' : typeof payload,
                        payloadKeys:
                            payload && typeof payload === 'object'
                                ? Object.keys(payload).slice(0, 8)
                                : [],
                        xfdfLength: xfdf.length,
                    });
                }

                if (xfdf) {
                    await annotationManager.importAnnotations(xfdf);
                    if (debug) {
                        console.log('[annot-debug] document-load:import-done', {
                            docId,
                            xfdfLength: xfdf.length,
                        });
                    }
                } else if (debug) {
                    console.warn('[annot-debug] document-load:import-skipped-no-xfdf-string', {
                        docId,
                    });
                }
            }

            if (readOnlyRef.current) annotationManager.enableReadOnlyMode?.();
            else annotationManager.disableReadOnlyMode?.();

            removeMarkedRedactionControls(instance, houderRef.current);

            // Nulmeting: de annotatiestand zoals die uit de werkkopie geladen is.
            // Hierop vallen we terug als de gebruiker bij het verlaten van de
            // pagina kiest voor "wijzigingen negeren".
            if (baselineXfdfRef) {
                try {
                    baselineXfdfRef.current =
                        shouldLoadAnnotations && !readOnlyRef.current
                            ? await annotationManager.exportAnnotations()
                            : null;
                } catch (baselineErr) {
                    baselineXfdfRef.current = null;
                    if (debug) {
                        console.warn('[annot-debug] document-load:baseline-failed', {
                            docId,
                            message: String(baselineErr?.message || baselineErr),
                        });
                    }
                }
            }
            setVuil?.(false);

            setStatus('klaar');

            if (debug) {
                console.log('[annot-debug] document-load:done', { docId, status: 'klaar' });
            }
        } catch (err) {
            setStatus('fout');

            if (debug) {
                console.error('[annot-debug] document-load:error', {
                    docId,
                    message: String(err?.message || err),
                    stack: String(err?.stack || ''),
                });
            }

            throw err;
        }
    };
}

export function onAnnotationChanged({
    instance,
    houderRef,
    readOnlyRef,
    loadAnnotationsRef,
    loadedContentUrlRef,
    primaryUrlRef,
    setWijzigingen,
    timerRef,
    AUTOSAVE_MS,
    api,
    docId,
    removeMarkedRedactionControls,
    annotationUserName,
    actieveGrondRef,
    actieveRawReasonRef,
    setVuil,
}) {
    return async function handleAnnotationChanged(annots, action, info) {
        const debug =
            window.__LAK_DEBUG_ANNOTS__ === true ||
            window.localStorage.getItem('lak.debug.annots') === '1';
        const t0 = performance.now();

        if (debug) {
            console.log('[annot-debug] changed:start', {
                action,
                imported: Boolean(info?.imported),
                readOnly: Boolean(readOnlyRef.current),
                loadAnnotations: Boolean(loadAnnotationsRef.current),
                isPrimary: loadedContentUrlRef.current === primaryUrlRef.current,
                count: Array.isArray(annots) ? annots.length : 0,
            });
        }

        if (info?.imported || readOnlyRef.current || !loadAnnotationsRef.current) return;
        if (loadedContentUrlRef.current !== primaryUrlRef.current) return;

        const annotationManager = instance.Core.annotationManager;
        const currentUser = String(
            annotationUserName || annotationManager.getCurrentUser?.() || '',
        ).trim();

        const isRedaction = (anno) => {
            const subject = String(anno?.Subject || anno?.subject || '').toLowerCase();
            const elementName = String(anno?.elementName || '').toLowerCase();
            return (
                subject === 'redaction' ||
                subject === 'redigeren' ||
                elementName === 'redact' ||
                anno?.constructor?.name === 'RedactionAnnotation'
            );
        };

        const lees = (anno, key) => {
            try {
                return String(anno.getCustomData?.(key) || '').trim();
            } catch {
                return '';
            }
        };

        const inferRefusalGroundFromOverlay = (overlayText) => {
            const text = String(overlayText || '').trim();
            const m = text.match(/^([0-9]+(?:\.[0-9]+)*(?:\.[a-z])?)/i);
            return m ? m[1] : '';
        };

        let anyMetadataChanged = false;

        const zetMetadata = (anno) => {
            if (!anno || typeof anno.setCustomData !== 'function') return;
            if (!isRedaction(anno)) return;

            const overlayText = String(anno.OverlayText || '').trim();
            const geselecteerdeGrond = actieveGrondRef?.current || null;

            const refusalGroundFromSelection = String(geselecteerdeGrond?.code || '').trim();
            const refusalGroundFromAnnot =
                lees(anno, 'dd_Refusal_Ground') ||
                lees(anno, 'refusalGround') ||
                inferRefusalGroundFromOverlay(overlayText);

            const refusalGround = refusalGroundFromSelection || refusalGroundFromAnnot;

            const rawReason =
                lees(anno, 'dd_Raw_Reason') ||
                lees(anno, 'dd_Annotation_Type') ||
                lees(anno, 'rawReason') ||
                String(actieveRawReasonRef?.current || '').trim() ||
                'NAME_GIVEN';

            let changed = false;

            if (currentUser) {
                changed = setCustomDataIfChanged(anno, 'author', currentUser) || changed;
                changed = setCustomDataIfChanged(anno, 'dd_author', currentUser) || changed;
            }

            changed = setCustomDataIfChanged(anno, 'commentType', 'redaction') || changed;
            changed = setCustomDataIfChanged(anno, 'dd_Raw_Reason', rawReason) || changed;
            changed = setCustomDataIfChanged(anno, 'dd_Annotation_Type', rawReason) || changed;

            if (refusalGround) {
                changed = setCustomDataIfChanged(anno, 'refusalGround', refusalGround) || changed;
                changed =
                    setCustomDataIfChanged(anno, 'dd_Refusal_Ground', refusalGround) || changed;
            }

            if (refusalGroundFromSelection && geselecteerdeGrond) {
                const overlay =
                    geselecteerdeGrond.summary ||
                    `${geselecteerdeGrond.code} ${geselecteerdeGrond.description || ''}`.trim();
                if (String(anno.OverlayText || '') !== overlay) {
                    anno.OverlayText = overlay;
                    changed = true;
                }
            }

            if (changed) {
                annotationManager.redrawAnnotation(anno);
                anyMetadataChanged = true;
            }

            if (actieveRawReasonRef) actieveRawReasonRef.current = rawReason;

            if (debug) {
                console.log('[annot-debug] changed:item', {
                    id: anno?.Id || null,
                    action,
                    changed,
                });
            }
        };

        if ((action === 'add' || action === 'modify') && Array.isArray(annots)) {
            for (const anno of annots) zetMetadata(anno);
        }

        removeMarkedRedactionControls(instance, houderRef.current);
        setWijzigingen(true);
        // setWijzigingen wordt door de autosave weer op false gezet; setVuil
        // blijft staan tot de gebruiker expliciet vastlegt of verwerpt.
        setVuil?.(true);
        clearTimeout(timerRef.current);

        timerRef.current = setTimeout(async () => {
            if (loadedContentUrlRef.current !== primaryUrlRef.current) return;
            const xfdf = await annotationManager.exportAnnotations();
            await api.bewaarAnnotaties(docId, xfdf).catch(() => {});
            removeMarkedRedactionControls(instance, houderRef.current);
            setWijzigingen(false);
            if (debug) {
                console.log('[annot-debug] autosave:done', {
                    xfdfLength: typeof xfdf === 'string' ? xfdf.length : 0,
                });
            }
        }, AUTOSAVE_MS);

        if (debug) {
            console.log('[annot-debug] changed:end', {
                action,
                anyMetadataChanged,
                ms: Math.round(performance.now() - t0),
            });
        }
    };
}

function setCustomDataIfChanged(anno, key, value) {
    const next = String(value ?? '').trim();
    const current = String(anno.getCustomData?.(key) ?? '').trim();
    if (current === next) return false;
    anno.setCustomData(key, next);
    return true;
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/src/viewer/webviewerHelpers.js =====
META: lines=291 bytes=8829
EXPORTS: applyViewerIdentity, AUTOSAVE_MS, collectMarkedRedactionControlRoots, REDACTION_ONLY_DISABLED_ELEMENTS, removeMarkedRedactionControls, startNotesPanelToggleCleanup, startRemoveMarkedRedactionControlsWatcher
SYMBOLS: const-fn:addRoot, const-fn:bindObservers, const-fn:cleanupObservers, const-fn:collectTargets, const-fn:onClickCapture, const-fn:onDocumentLoaded, const-fn:refreshTargetsAndBind, const-fn:scheduleCleanup, const-fn:scheduleRerun, const-fn:shouldRemoveButton, const-fn:walkNode, fn:applyViewerIdentity, fn:collectMarkedRedactionControlRoots, fn:removeMarkedRedactionControls, fn:startNotesPanelToggleCleanup, fn:startRemoveMarkedRedactionControlsWatcher
----- CODE START -----
export const AUTOSAVE_MS = 1500;

export const REDACTION_ONLY_DISABLED_ELEMENTS = [
    'toolbarGroup-Shapes',
    'toolbarGroup-Edit',
    'toolbarGroup-Insert',
    'toolbarGroup-View',
    'toolbarGroup-Annotate',
    'toolbarGroup-FillAndSign',
    'toolbarGroup-Forms',
    'toolbarGroup-EditText',
    'highlightToolGroupButton',
    'underlineToolGroupButton',
    'strikeoutToolGroupButton',
    'squigglyToolGroupButton',
    'freeTextToolGroupButton',
    'shapeToolGroupButton',
    'freeHandToolGroupButton',
    'freeHandHighlightToolGroupButton',
    'stickyToolGroupButton',
    'downloadButton',
    'printButton',
    'languageButton',
    'themeChangeButton',
    'contextMenuPopup',
    'redactionButton2',
    'redactionButton3',
    'redactionButton4',
    'modalRedactButton',
    'redactAllMarkedButton',
    'clearAllMarkedButton',
];

export function collectMarkedRedactionControlRoots(instance, hostElement) {
    const roots = [];
    const rootSeen = new Set();
    const walkSeen = new Set();

    const addRoot = (root) => {
        if (!root || rootSeen.has(root)) return;
        rootSeen.add(root);
        roots.push(root);
    };

    const walkNode = (node) => {
        if (!node || walkSeen.has(node)) return;
        walkSeen.add(node);
        if (node.nodeType === Node.DOCUMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
            addRoot(node);
        }
        if (node.shadowRoot) walkNode(node.shadowRoot);
        if (!node.querySelectorAll) return;
        for (const el of node.querySelectorAll('*')) {
            if (el.shadowRoot) walkNode(el.shadowRoot);
        }
    };

    addRoot(document);
    addRoot(hostElement?.ownerDocument);
    addRoot(hostElement);
    addRoot(instance?.UI?.iframeWindow?.document);
    addRoot(instance?.UI?.iframeWindow?.document?.documentElement);
    addRoot(instance?.UI?.iframeWindow?.document?.body);
    for (const root of [...roots]) walkNode(root);

    return roots.filter(Boolean);
}

export function startNotesPanelToggleCleanup(instance, hostElement) {
    if (!instance) return () => {};

    let disposed = false;
    const unsubs = [];
    let timeoutId = null;
    let rafId = null;

    const scheduleCleanup = () => {
        if (disposed) return;

        if (rafId != null) cancelAnimationFrame(rafId);
        rafId = requestAnimationFrame(() => {
            if (disposed) return;
            removeMarkedRedactionControls(instance, hostElement);

            if (timeoutId != null) clearTimeout(timeoutId);
            timeoutId = setTimeout(() => {
                if (disposed) return;
                removeMarkedRedactionControls(instance, hostElement);
            }, 80);
        });
    };

    const onClickCapture = (evt) => {
        const target = evt?.target;
        if (!target || typeof target.closest !== 'function') return;
        const toggle = target.closest('button[data-element="notesPanelToggle"]');
        if (!toggle) return;
        scheduleCleanup();
    };

    const docs = new Set();
    const roots = collectMarkedRedactionControlRoots(instance, hostElement);

    for (const root of roots) {
        if (root instanceof Document) docs.add(root);
        else if (root?.ownerDocument) docs.add(root.ownerDocument);
    }

    for (const doc of docs) {
        try {
            doc.addEventListener('click', onClickCapture, true);
            unsubs.push(() => doc.removeEventListener('click', onClickCapture, true));
        } catch {
            // ignore
        }
    }

    return () => {
        disposed = true;
        if (rafId != null) cancelAnimationFrame(rafId);
        if (timeoutId != null) clearTimeout(timeoutId);
        for (const off of unsubs) {
            try {
                off();
            } catch {
                // ignore
            }
        }
    };
}

export function removeMarkedRedactionControls(instance, hostElement) {
    if (!instance) return;

    try {
        instance.UI.disableElements(['redactAllMarkedButton', 'clearAllMarkedButton']);
    } catch {
        // ignore
    }

    try {
        const roots = collectMarkedRedactionControlRoots(instance, hostElement);

        const shouldRemoveButton = (button) => {
            if (!button) return false;
            if (
                button.classList?.contains('clear-all-marked') ||
                button.classList?.contains('redact-all-marked')
            )
                return true;
            const text = String(button.textContent || '')
                .replace(/\s+/g, ' ')
                .trim()
                .toLowerCase();
            return [
                'duidelijk',
                'alles redigeren',
                'clear',
                'clear all marked',
                'redact all marked',
            ].includes(text);
        };

        for (const root of roots) {
            if (!root?.querySelectorAll) continue;
            const panels = root.querySelectorAll('.redaction-panel-controls');
            for (const panel of panels) {
                const buttons = panel.querySelectorAll('button, [role="button"]');
                if ([...buttons].some(shouldRemoveButton) && panel.isConnected) panel.remove();
            }
            const looseButtons = root.querySelectorAll(
                'button.clear-all-marked, button.redact-all-marked, button, [role="button"]',
            );
            for (const button of looseButtons) {
                if (!button.isConnected || !shouldRemoveButton(button)) continue;
                const panel = button.closest?.('.redaction-panel-controls');
                if (panel?.isConnected) panel.remove();
                else button.remove();
            }
        }
    } catch {
        // ignore DOM inspection errors
    }
}

export function startRemoveMarkedRedactionControlsWatcher(instance, hostElement) {
    if (!instance) return () => {};

    const observers = [];
    const targets = new Set();
    let disposed = false;
    let rafId = null;
    let intervalId = null;

    const cleanupObservers = () => {
        while (observers.length) {
            const obs = observers.pop();
            try {
                obs.disconnect();
            } catch {
                // ignore
            }
        }
    };

    const scheduleRerun = () => {
        if (disposed || rafId !== null) return;
        rafId = window.requestAnimationFrame(() => {
            rafId = null;
            if (disposed) return;
            removeMarkedRedactionControls(instance, hostElement);
        });
    };

    const collectTargets = () => {
        const roots = collectMarkedRedactionControlRoots(instance, hostElement);
        for (const root of roots) {
            if (root instanceof Document) {
                if (root.documentElement) targets.add(root.documentElement);
                if (root.body) targets.add(root.body);
            } else if (root instanceof ShadowRoot || root instanceof HTMLElement) {
                targets.add(root);
            }
        }
    };

    const bindObservers = () => {
        cleanupObservers();
        for (const target of targets) {
            try {
                const observer = new MutationObserver(() => {
                    scheduleRerun();
                });
                observer.observe(target, {
                    childList: true,
                    subtree: true,
                });
                observers.push(observer);
            } catch {
                // ignore
            }
        }
    };

    const refreshTargetsAndBind = () => {
        collectTargets();
        bindObservers();
        scheduleRerun();
    };

    refreshTargetsAndBind();

    const onDocumentLoaded = () => {
        refreshTargetsAndBind();
    };

    try {
        instance.Core?.documentViewer?.addEventListener('documentLoaded', onDocumentLoaded);
    } catch {
        // ignore
    }

    intervalId = window.setInterval(() => {
        refreshTargetsAndBind();
    }, 3000);

    return () => {
        disposed = true;
        if (rafId !== null) window.cancelAnimationFrame(rafId);
        if (intervalId !== null) window.clearInterval(intervalId);
        cleanupObservers();
        try {
            instance.Core?.documentViewer?.removeEventListener('documentLoaded', onDocumentLoaded);
        } catch {
            // ignore
        }
    };
}

export function applyViewerIdentity(instance, annotationUserName) {
    const username = String(annotationUserName || '').trim();
    if (!instance || !username) return;
    try {
        const am = instance?.Core?.annotationManager;
        if (am && typeof am.setCurrentUser === 'function') am.setCurrentUser(username);
        if (am && typeof am.setIsAdminUser === 'function') am.setIsAdminUser(true);
    } catch {
        // noop
    }
}

----- CODE END -----


===== FILE: woo-standalone-ui-frontend/vite.config.js =====
META: lines=22 bytes=639
IMPORTS: @vitejs/plugin-react, vite
----- CODE START -----
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// In dev proxien we de BFF; in productie serveert de UI-pod de gebouwde assets
// en routeert de ingress /api naar de BFF en /webviewer naar de WebViewer-pod.
export default defineConfig({
    plugins: [react()],
    server: {
        port: 5173,
        proxy: {
            "/api": {
                target: process.env.VITE_PROXY_TARGET || "http://host.docker.internal:4000",
                changeOrigin: true,
            },
        },
        watch: {
            usePolling: true,
            interval: 100,
        },
    },
});

----- CODE END -----
