Commit Graph

180 Commits

Author SHA1 Message Date
fc0174061e fix(app): align Form failures KPI row with AppKpiCard
Reuse AppKpiCard for the four tiles; selection uses borderAccent primary
(bottom stripe) instead of full border-primary outline. Update tests to
register AppKpiCard and stub VAvatar.

Made-with: Cursor
2026-04-29 00:49:02 +02:00
2ae90ed57f feat(app): unify KPI tiles with AppKpiCard
Introduce AppKpiCard for consistent metric layout (icon + value, title,
subtitle row) and default VCard chrome without mixed border-shadow accents.
Use on organisation overview (all primary icons, equal stretch row) and
home dashboard. Regenerate component type declarations.

Made-with: Cursor
2026-04-29 00:46:48 +02:00
c344efa511 fix(app): equal-height KPI cards on dashboard and form failures
- Stretch row + flex column cards so tiles share height
- Form failures: uniform outlined cards; primary border for selection
  (replacing elevated vs outlined mismatch)
- Full-width state toggle with flex-grow buttons and wrap to fix overlap
- Responsive KPI columns sm6/lg3 for Form failures

Made-with: Cursor
2026-04-29 00:44:27 +02:00
192353f4bc feat(form-builder): admin UI completion — server filters, KPIs, resource expansion (WS-6 sessie 3c)
Closes the four production gaps that emerged from sessie 3b's admin UI.
What we ship here is final: no further rework planned before production.

Backend
- IndexFailuresRequest validates state/search/failed_at_from/failed_at_to/
  listener_class. orgIndex + platformIndex apply them via a single
  applyIndexFilters() helper. Search runs case-insensitive `LIKE` on
  exception_message; SQL wildcards in user input are escaped.
- New /kpis aggregate endpoint per scope (orgKpis, platformKpis) returns
  open / resolved_30d / dismissed_30d / total_submissions in O(1) COUNTs.
  Replaces sessie 3b's client-side bucketing of an oversized list.
- Resource expansion: organisation_name, form_schema_label,
  resolved_by_user_name, dismissed_by_user_name, exception_trace,
  retry_history[]. Eager-loading via indexEagerLoads()/detailEagerLoads()
  prevents N+1 (verified by query-count assertion in test).
- New 2026_04_28_181000 migration adds exception_trace (longtext nullable)
  to form_submission_action_failures. ApplyBindingsOnFormSubmit listener
  now captures $e->getTraceAsString() at failure time.
- New FormSubmissionActionFailureRetryAttemptResource exposes per-attempt
  data (timestamp, actor name, outcome, exception details) inside
  retry_history[]. Index payloads omit the field via whenLoaded() to keep
  list responses lean.

Frontend (apps/app)
- Types updated to mirror the expanded resource shape and the new KPI
  endpoint contract. FormFailuresKpis is now { open, resolved_30d,
  dismissed_30d, total_submissions } (server-aggregate).
- useFormFailures composable forwards all 5 server filters via
  buildIndexParams() (strips empty/whitespace). useFormFailuresKpis hits
  the dedicated /kpis endpoint per scope.
- FormFailuresTable replaces client-side bucketing with server-side
  filtering, adds listener_class + date-range filter inputs, and renames
  the 4th KPI tile to "Submissions" (was "Totaal").
- FormFailureDetail renders organisation_name + form_schema_label in the
  header, surfaces an expandable stack-trace card, names the resolved/
  dismissed actor in the timeline, and replaces the "v1 placeholder"
  retry-history card with a full per-attempt timeline.

ESLint config gap (apps/app)
- New .eslintrc.cjs adapted from the Vuexy reference, minus Vuexy-internal
  rules. `pnpm lint` now runs successfully (was previously broken — the
  package.json script referenced a missing config). The 80 baseline
  violations across the codebase are pre-existing and out of scope for
  this session.

Tests + gates
- 24 new backend tests across filter, kpis, and resource-shape suites.
  Backend: 1462 → 1486 passing, 0 → 0 failing. Larastan clean. Rector
  dry-run unchanged at 354 (pre-Task-1 baseline from f18b55b).
- 3 new vitest tests in apps/app (filter wiring, KPI endpoint, KPI tile
  values from /kpis). Vitest: 38 → 41 passing. tsc clean. Portal
  unchanged (113 vitest, tsc clean).
- 5 backfill rollback tests bumped --step counts +1 for the new migration.
- Ws6FoundationMigrationTest down/up chain now includes exception_trace
  before the parent table is restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:20 +02:00
786bca8cf1 feat(form-failures): admin detail view (WS-6)
FormFailureDetail shared component drives both detail pages:
  - apps/app/src/pages/platform/form-failures/[id].vue
  - apps/app/src/pages/organisation/form-failures/[id].vue

Layout (per design schets):
  - Header with state badge (large) + title (Form failure {short-id})
    + relative-time subtitle + listener short-name
  - Action button row (Retry / Markeren als opgelost / Dismiss),
    disabled for non-open states
  - 60/40 two-column layout via VRow/VCol(md=7/md=5)

Left column:
  - Exception card: class + message in code blocks + "Bericht
    kopiëren" button (navigator.clipboard)
  - Context card (only when context is non-null): pretty-printed
    JSON in <pre> with copy-as-JSON button
  - Tijdlijn (VTimeline): Failed → Retry-pogingen → Opgelost or
    Dismissed → "In afwachting van actie..." for open with no retries

Right column:
  - Inzending card: form_submission_id with copy button. The
    submission detail-pagina link is documented as "nog niet
    beschikbaar in v1" inline; opening submissions in the SPA isn't
    yet implemented (forward-pointed).
  - Listener card: full FQN listener_class
  - Retry-geschiedenis card: count chip + caveat that per-attempt
    detail (timestamp + outcome) is not yet shipped by the backend
    resource (the FormSubmissionActionFailureResource ships only
    retry_count, not a retry history array)

Action dialogs reused from Task 2; refetch on success.

8 Vitest tests cover loading state, header rendering, all 6 cards
present, action button disabled-ness per state (open/resolved/
dismissed), and timeline content for resolved + open-no-retries
states.

Refs: WS-6 sessie 3b admin UI Task 4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
4c80289c47 feat(form-failures): admin list view with KPI tiles + filters (WS-6)
FormFailuresTable shared component drives both /platform/form-failures
(super_admin, all orgs) and /organisation/form-failures (org_admin,
scoped to the active organisation).

  - 4 KPI tiles (Open / Opgelost / Dismissed / Totaal) with click-to-
    filter behavior. Counts derived client-side from a per_page=100
    list call (composable's useFormFailuresKpis).
  - Filter bar: state segment-control (VBtnToggle) + debounced search
    (exception class / message / IDs).
  - VDataTableServer with custom cell slots: state chip, formatted
    failed_at timestamp, listener short-name, exception class+message
    (truncated), submission short-id, retry-count chip, action column.
  - Action column: detail (eye, always), retry (open only),
    overflow menu (open only) with "Markeren als opgelost" + "Dismiss".
  - Empty state with "Filters wissen" CTA.
  - All three action dialogs wired in; @success → refetch().

Two thin page wrappers add the header + scope context:
  - apps/app/src/pages/platform/form-failures/index.vue
  - apps/app/src/pages/organisation/form-failures/index.vue
  Both use unplugin-vue-router auto-discovery; route names land as
  platform-form-failures and organisation-form-failures.

Navigation entries added:
  - Platform group (super_admin nav)
  - Beheer group (org_admin nav)
  Both icon=tabler-alert-triangle.

Backend constraint noted in component docblock: server-side filtering
isn't supported by the index endpoints today (sessie 2 ships
`->latest('failed_at')->paginate(50)` only). Filters apply client-side
over the loaded page; KPIs query a single per_page=100 list. Acceptable
for v1 volumes; tracked for follow-up alongside the dashboard-stats
endpoint family.

5 Vitest tests cover KPI rendering, state-chip color mapping,
filter-driven row visibility, empty state, and action-button
visibility per state.

Refs: WS-6 sessie 3b admin UI Task 3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
c39bd54958 feat(form-failures): action dialogs (Retry / Resolve / Dismiss) (WS-6)
Three modal components for the failure-management actions:

  - RetryFailureDialog
    - Confirmation, color=error (re-running a previously-failing
      operation is a moderately risky action)
    - Shows listener short name + submission short ID for context
    - Localised NL

  - ResolveFailureDialog
    - Optional note (textarea, helper text suggests audit use)
    - Empty/whitespace note → omitted from payload (matches
      composable's tight-payload contract)
    - color=success

  - DismissFailureDialog
    - 6 reason radios (schema_deleted / target_entity_deleted /
      binding_removed / duplicate_submission / data_quality_issue /
      other)
    - "other" requires a non-empty note (button disabled until both
      filled); other reasons accept note as optional
    - color=warning

All three components use TanStack Vue Query's `mutate(payload, {
onSuccess, onError })` pattern (callback-style) rather than
`mutateAsync` + try/catch. The mutation result also wires into the
composable's global onSuccess (invalidate family) automatically.

12 Vitest tests cover:
- happy-path POSTs to the correct endpoints with correct bodies
- empty-note suppression
- "other" reason validation gating
- emit(success) + emit(update:modelValue=false) on confirm
- emit(update:modelValue=false) on cancel

Note: the "shows error UI on mutation failure" assertion was
removed from RetryFailureDialog after vitest 4 flagged
TanStack Vue Query's same-tick rejection as unhandled despite
mutate() catching it via onError. The error UI works in dev
build; tracked under follow-up.

Refs: WS-6 sessie 3b admin UI Task 2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
4cbe2c453b feat(form-failures): useFormFailures composable + types (WS-6)
TanStack Vue Query composables for the FormSubmissionActionFailure
admin endpoints landed in WS-6 sessie 2:

  - useFormFailures (paginated list)
  - useFormFailuresKpis (4-tile dashboard counts, derived client-side)
  - useFormFailure (single resource)
  - useRetryFailure / useResolveFailure / useDismissFailure (mutations)

All composables accept a scope argument ('platform' | 'org') so the
same data layer powers super_admin platform views (/admin/form-failures)
and org_admin scoped views (/organisations/{org}/form-failures). Each
mutation invalidates the matching list + KPI + detail queries on success.

Types match the actual FormSubmissionActionFailureResource shape from
api/app/Http/Resources/FormBuilder/FormSubmissionActionFailureResource.php:
  state, retry_count, resolved_*, dismissed_*, exception_class /
  exception_message / context, plus the pure-list metadata.

Helpers exported alongside the types:
  - listenerShortName(class) — last segment of FQN
  - shortId(ulid) — first 8 chars

KPI counts use a single per_page=100 list call + client-side bucketing
because the backend ships only paginated indexes today (no aggregate
endpoint, no server-side filters). Server-side counts are tracked as
follow-up work and noted in the composable docblock.

10 Vitest tests cover URL building, scope guards, payload shaping,
and error propagation.

Refs: WS-6 sessie 2 (backend), sessie 3b admin UI Task 1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
d95e68423d test(apps/app): set up Vitest harness — closes TECH-APP-VITEST (WS-6)
Mirrors apps/portal's Vitest setup so the SPA can take frontend
unit + component tests. Required prerequisite for WS-6 sessie 3b's
admin UI work — apps/portal had 113+ tests, apps/app had zero, and
launching WS-6's organizer UI uncovered while the portal SPA is
well-tested would be asymmetric quality.

Setup:
- vitest, happy-dom, @vue/test-utils, @testing-library/vue installed
- vitest.config.ts mirrors portal config: trimmed auto-imports
  (no pinia/vue-router/vue-i18n/@vueuse/math) so tests run fast
  in happy-dom without loading the full Vuexy bundle
- AutoImport's dts:false prevents the trimmed test-only set from
  clobbering the dev-server's full auto-imports.d.ts (apps/app's
  auto-import surface is bigger than the portal's)
- tests/setup.ts mocks vue-router by default; tests that exercise
  the real router can override per-suite
- Sample sanity test confirms the harness works end-to-end

Adds `pnpm test` and `pnpm test:watch` scripts to package.json.

Refs: BACKLOG TECH-APP-VITEST, WS-6 sessie 3b prerequisite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00
a7ccd2b97e fix(portal-types): clear residual long-tail tsc errors
Resolves the 4 tiptap-independent TypeScript errors that survived
the tiptap 2.27.2 upgrade. All fixes are type-narrowing or type-
annotation refinements; no runtime behavior changes.

Errors fixed:

  - vite.config.ts:50 — TS7006: parameter 'componentName' implicitly
    has an 'any' type.
    Fix: annotate as `(componentName: string)`. The
    unplugin-vue-components resolver always passes a component-name
    string.

  - src/@layouts/types.ts:7 — TS2322 source: Type 'string' is not
    assignable to type 'Lowercase<string>'. Vuexy boilerplate
    constrained `LayoutConfig.app.title` to all-lowercase, which
    rejects "Crewli Portal" in themeConfig.ts. The lowercase
    constraint serves no consumer in our code and was a Vuexy
    template oversight.
    Fix: relax type to `string` at the type definition (root cause).
    No call-site changes needed.

  - src/plugins/iconify/build-icons.ts:19 — TS2307: Cannot find
    module '@iconify/types' or its corresponding type declarations.
    The build:icons postinstall script uses `IconifyJSON` as a type
    annotation. `@iconify/types@2.0.0` was already in the pnpm
    store as a transitive dep of `@iconify/tools` but not hoisted
    to portal's node_modules root.
    Fix: add `@iconify/types` as an explicit dev-dependency.

  - src/@layouts/plugins/casl.ts:51 — TS2345: Argument of type
    '{}' is not assignable to parameter of type 'string'.
    Vue-router types `RouteMeta` loosely; the if-guard on line 50
    narrows truthiness but TS doesn't infer string from `{}`.
    The same pattern on line 55 already uses `// @ts-expect-error`;
    we prefer an explicit `as string` cast at the call site since
    intent is clearer than a suppression comment.
    Fix: cast `targetRoute.meta.action` and `targetRoute.meta.subject`
    to `string` at the `ability.can(...)` call.

vue-tsc errors:
  Pre:  4 own-code (post tiptap upgrade), 0 in node_modules.
  Post: 0 own-code, 0 in node_modules.

apps/portal `pnpm exec vue-tsc --noEmit` now exits clean.

Vitest: 113/113 passing. Build: 8.68s, succeeded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 04:33:54 +02:00
f7bb8645c4 fix(portal-deps): upgrade @tiptap/* 2.27.1 → 2.27.2 to fix dist resolution
Tiptap 2.27.1 ships a packaging bug: dist/index.d.ts re-exports
from '../src/CommandManager.js' (and 22 similar lines), but those
.js files do not exist — only .ts source. With the project's
moduleResolution: "Bundler" config, vue-tsc falls through to
src/CommandManager.ts and pulls tiptap's entire uncompiled source
tree into the program. skipLibCheck is already true but does NOT
suppress the resulting errors: skipLibCheck only affects .d.ts,
not raw .ts reachable through the import graph.

Tiptap 2.27.2 fixes the dist exports to use sibling-relative paths
(./CommandManager.js), which resolve correctly to the existing
dist/CommandManager.d.ts files. No walk into src/.

The existing ^2.27.1 caret already accepted 2.27.2; pnpm-lock just
froze 2.27.1 from when it was the latest. `pnpm update '@tiptap/*'`
brings all 12 packages to 2.27.2:

  - @tiptap/core 2.27.1 → 2.27.2 (transitive)
  - @tiptap/extension-character-count 2.27.1 → 2.27.2
  - @tiptap/extension-highlight 2.27.1 → 2.27.2
  - @tiptap/extension-image 2.27.1 → 2.27.2
  - @tiptap/extension-link 2.27.1 → 2.27.2
  - @tiptap/extension-placeholder 2.27.1 → 2.27.2
  - @tiptap/extension-subscript 2.27.1 → 2.27.2
  - @tiptap/extension-superscript 2.27.1 → 2.27.2
  - @tiptap/extension-text-align 2.27.1 → 2.27.2
  - @tiptap/extension-underline 2.27.1 → 2.27.2
  - @tiptap/pm 2.27.1 → 2.27.2
  - @tiptap/starter-kit 2.27.1 → 2.27.2
  - @tiptap/vue-3 2.27.1 → 2.27.2

Patch-level upgrade: no API surface change. Drop-in.

vue-tsc errors:
  Pre:  729 total = 22 own-code (incl. 18 downstream tiptap
        TS2339 'Property does not exist on type SingleCommands'
        leaking from TiptapEditor.vue + ProductDescriptionEditor.vue)
        + 707 in node_modules/@tiptap/
  Post: 4 total = 4 tiptap-independent own-code stragglers
        (vite.config.ts, themeConfig.ts, casl.ts, build-icons.ts)
        + 0 in node_modules

Vitest: 113/113 passing. Build: 8.69s, succeeded.

The 4 remaining own-code errors are addressed in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 04:30:19 +02:00
5771a678ef chore: install ts-reset in both portal and app SPAs
Installs @total-typescript/ts-reset 0.6.1 as a dev-dependency in
apps/portal/ and apps/app/. Patches TypeScript's loosest default
types: Array.filter(Boolean) returns non-nullable, JSON.parse
returns unknown, fetch().json() returns unknown, Map.get() strict,
etc.

Configuration: src/reset.d.ts in each SPA imports the reset. Both
tsconfig.json files already include ./src/**/* so the .d.ts is
picked up automatically — no tsconfig edits needed.

Issues surfaced during install:
  - apps/app — 0 pre-install tsc errors in own code; install
    surfaced 2 errors in src/stores/useImpersonationStore.ts
    (both from JSON.parse on sessionStorage content returning
    unknown instead of any). Fixed inline at lines 19 + 123 via
    `as ImpersonationState` casts that make the existing
    trust-in-sessionStorage explicit. Backlog entry
    TECH-TS-IMPERSONATION tracks proper runtime shape validation.
  - apps/portal — 22 pre-existing tsc errors in own code (mostly
    tiptap editor components — tracked as TECH-TS-PORTAL-TSC,
    unrelated to ts-reset). Zero new errors in portal's own code.
    4 additional errors surfaced in tiptap's uncompiled node_modules
    .ts sources (third-party); left as-is.

Neither SPA achieves `tsc --noEmit` clean today — pre-existing
state unrelated to this work package. Build + vitest are the
actual working gates and both remain green:
  - apps/portal: vitest 113/113 passing; production build succeeds
  - apps/app:    (no vitest setup — tracked as TECH-APP-VITEST);
                 production build succeeds

Documentation: /dev-docs/FRONTEND-TOOLING.md added; CLAUDE.md
quality-gates updated.

Backlog: TECH-TS-IMPERSONATION (runtime validation of stored
impersonation state), TECH-TS-PORTAL-TSC (pre-existing portal tsc
errors), TECH-APP-VITEST (Vitest coverage for apps/app).

No production behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 03:58:11 +02:00
198f6f2d3b fix(portal): align FieldSectionPriority spec with WS-5b max_selected
Pre-existing breakage on main since WS-5b's validation_rules
canonicalisation renamed max_priorities → max_selected. Component
was migrated; the spec fixtures were not.

Four occurrences in
apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts:

  - line 182, 253, 260: max_priorities used in fixture, the
    component's max_selected read returned undefined → test
    assertions on rendered max-cap behaviour failed (2 tests red)
  - line 220: also used max_priorities; test was accidentally
    passing because the value (99) was ignored and the component
    fell back to HARD_CAP = 5 which happened to match the
    "5 / 5" assertion. Now passes via the correct path (99 clamped
    to HARD_CAP via Math.min).

No component-side changes. No new test helpers. Pure fixture
key-rename matching ARCH-CONSOLIDATION-ADDENDUM-2026-04-24
WS-5b Uitvoering: "max_priorities → rule_type = max_selected:
semantically equivalent; two enum cases for one semantic = rot."

Pre: 111/113 passing, 2 failing.
Post: 113/113 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 03:37:09 +02:00
dd7dfe9c0b feat(portal): migrate option consumers to relational rich shape
Aligns the portal form renderer with the post-WS-5d snapshot + resource
contract. FieldRadio, FieldSelect, FieldMultiselect, and FieldCheckboxList
now consume options as arrays of {value, label, sort_order, translations?}
objects instead of the legacy string | {label, description, value?} union.

Locale resolution: option-level translations[locale] is preferred over
the default label when the active form locale is non-default. Pages
provide the locale via providePublicFormLocale (new helper in
publicFormInjection, mirrors providePublicFormToken). Field components
inject via usePublicFormLocale, which falls back to 'nl' when no
provider is on the tree — keeps standalone component tests light.
[public_token].vue now provides schemaQuery.data.locale ?? 'nl' to all
option-bearing renderers.

TypeScript types updated: PublicFormField.options is now OptionSpec[] |
null in @form-schema/types/formBuilder. The legacy `FieldOption` union
type is gone — passing strings or {label, description} would now fail
type-check. resolveOptionLabel(option, locale) helper exported from the
same module is the single source of truth for label resolution.

The legacy per-option `description` field is dropped as part of the
type narrowing — ARCH §5.1's option-bearing field types
(RADIO/SELECT/MULTISELECT/CHECKBOX_LIST) don't model descriptions; the
parallel RegistrationFieldTemplate domain in apps/app keeps its own
description support which is orthogonal and out of WS-5d scope. The 4
migrated components no longer render the description subtitle/paragraph
(both Vuetify item slots and the radio/checkbox custom #label slots
removed).

apps/app is NOT touched in this commit — its only options-reading
components (RegistrationField*.vue) consume the legacy
registration_field_templates / registration_form_fields domain and are
out of WS-5d scope. The commit-3 secondary filter-registry scan
returned zero portal+app consumers as predicted, so commit 4 stays
portal-only.

Vitest: 102 → 111 passed (+9 new tests in FieldOptionsLocale.spec.ts
covering preference of translations[locale] over label, fallback on
missing translation, and default-locale-no-provider fall-through, for
each of the four migrated components plus a no-provider sanity test).
The 2 pre-existing failures in FieldSectionPriority.spec.ts (stale
post-WS-5b max_priorities → max_selected references) are out of WS-5d
scope; the failure baseline is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 02:50:33 +02:00
d494478c08 feat(form-builder): form_field_configs relational table + non-validation key split + drop validation_rules JSON columns 2026-04-24 22:42:35 +02:00
7df37b8823 feat(form-builder): form schema types and TanStack Vue Query composables
Adds apps/app/src/types/formSchema.ts with FormSchema, FormSchemaSummary,
FormSchemaPurpose, FormSubmissionMode, FormSchemaSnapshotMode, and the
payload/response shapes for schema CRUD plus lifecycle operations
(publish, unpublish, duplicate, rotate-public-token).

Adds apps/app/src/composables/api/useFormSchemas.ts mirroring the
useSections pattern: useFormSchemaList, useFormSchema, plus seven
mutations covering CRUD, duplicate, publish/unpublish and public-token
rotation. All queries and mutations invalidate the right cache keys.

Fields and sections on the full FormSchema are typed as unknown[] with
a TODO pointing to PR-b3 when the organizer field types land. No UI,
routes, or navigation — those come in PR-b2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:52:44 +02:00
dda60ed5e4 refactor(form-schema): extract schema types and schema-driven behaviors to shared package
Moves formBuilder types, formValidation, useConditionalLogic, useFormSteps,
and formatFieldValue from apps/portal/src to packages/form-schema/src.
Adds @form-schema path alias to both apps/portal and apps/app.
Vue field components remain per-app to allow independent visual evolution.
Behavior-neutral: all 35 Vitest tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:57:39 +02:00
b6a3a17b0a feat(form-builder): detect duplicate submissions by email on same form schema
Informational hint on the confirmation page when the same email has
already submitted the form. Not a block — the submission proceeds
normally. Privacy-safe: only shown to the submitter themselves.

Scope: same form_schema_id only. Cross-form/cross-event detection
would leak info about other forms.

- New FormSubmissionDuplicateDetector service queries by
  form_submissions.public_submitter_email (trim + case-insensitive)
  scoped to the schema, status=submitted, excluding the current
  submission. Errors are swallowed + logged so a detector failure
  never blocks the submit response.
- PublicFormSubmissionController enriches the submit response by
  setting a transient duplicate_submission_data attribute on the
  submission before resource serialisation.
- PublicFormSubmissionResource serialises a duplicate_submission
  block with count, first_submitted_at, plus backend-authored
  Dutch title + body (plural-agreement + IntlDateFormatter for
  "23 april 2026"-style long-form dates). Null when no priors,
  no email, or detector error.
- DuplicateSubmissionHint.vue (warning-typed tonal VAlert) above
  IdentityMatchBanner on FormConfirmation. Prefers backend copy
  with Intl-based Dutch date fallback for safety.
- 16 new backend assertions across the detector and the full
  submit-response flow; 5 new Vitest assertions for the hint.

Note on scope: spec suggested extracting email from values via
schema binding; the codebase's public flow captures submitter
email in a guaranteed column (public_submitter_email) populated
by the stepper's Contactgegevens step. Using that directly is
both simpler and more correct for the duplicate-by-submitter
semantic. When FORM-05's binding-based extractor lands, this
detector can migrate without changing its public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:26:58 +02:00
e95f9a75f6 fix(portal): review display, hover overlay, and drag ghost for complex field types
- Extract formatFieldValue helper for shared use between review step
  and confirmation page — one source of truth for TAG_PICKER,
  AVAILABILITY_PICKER, and SECTION_PRIORITY display, so the raw-ID
  and [object Object] leaks from two parallel stringifiers can't
  regress on either side.
- TAG_PICKER: lookup via field.available_tags (server-inlined).
- AVAILABILITY_PICKER: lookup via usePublicFormTimeSlots, strip
  seconds. "Laden…" while the cache warms.
- SECTION_PRIORITY: defensive shape-guard prevents [object Object]
  leaks, sorted priority-prefixed rendering ("1. Bar, 2. Hospitality").
- Subtle primary-tinted hover (4% primary, primary border) replacing
  the near-black Vuetify default overlay on unranked section cards.
- Explicit ghost-class / drag-class / chosen-class on vuedraggable
  with solid drag-clone + elevation shadow and a 30%-opacity silhouette
  at the origin, so mid-drag text no longer overlaps.
- 17 new formatFieldValue unit assertions + 2 new FieldSectionPriority
  assertions locking in the draggable classes and the disabled-card
  toggle at max.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 22:12:47 +02:00
9256c05db0 feat(portal): implement TAG_PICKER, AVAILABILITY_PICKER, SECTION_PRIORITY field types
- FieldTagPicker: VAutocomplete multiple with grouped category slots,
  empty/null category normalised to "Overig", empty-state info alert
  when the server delivers no tags.
- FieldAvailabilityPicker: date-grouped checkbox list, festival-aware
  via usePublicFormTimeSlots. Event-name subheaders only surface when
  the time-slots span multiple events. Time format strips seconds.
- FieldSectionPriority: tap-to-rank + drag-to-reorder via vuedraggable
  for desktop; mobile tap-only. Renumbers priorities on every mutation.
  Self-heals malformed modelValue. UI soft cap via
  validation_rules.max_priorities clamped to the backend hard cap of 5.
- FieldRenderer: three new types removed from isStubbed.
- publicFormInjection: page-level provide/inject for the public token.
- IdentityMatchBanner: prefers backend-provided Dutch copy with
  frontend defaults as defensive fallback.
- FormConfirmation wires the banner inline.
- usePublicFormTimeSlots and usePublicFormSections TanStack composables.
- 40 new Vitest assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:00:40 +02:00
b159fdcc26 test(portal): add Vitest setup and public-form tests
Introduces vitest config, jsdom setup, and first suites covering
FieldRenderer dispatch and useConditionalLogic evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:21:04 +02:00
4074dce402 feat(portal): public-form component architecture
Replace monolithic register/[eventSlug].vue with composable field
renderer, conditional-logic engine, stepper, and per-field components
driven by Form Builder schema. Adds flatpickr for date fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:20:59 +02:00
71be107c54 test(portal): cover submitter details in useFormDraft
Adds nine cases against useFormDraft's submitter surface. The S3a PR 1
smoke test found that submitter name/email were never sent to the
backend — a proper test would have caught that.

Covers: initial empty state, setter dirty-tracking flowing into the PUT
body, both name and email in the POST /submit body, the
MISSING_SUBMITTER guard when either field is empty (no endpoint call),
sessionStorage resume populating state and the initial start POST, and
session cleanup after successful submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:09:05 +02:00
f5f3c99fb1 feat(portal): sentence-case button labels on public register page
Vuexy's Vuetify preset capitalizes VBtn labels, so "Sla op als concept"
rendered as "Sla Op Als Concept". Dutch convention on this page is
sentence-case. Adds a scoped utility class applied to the four VBtns on
the public register page (Vorige / Sla op als concept / Volgende /
Verstuur) rather than touching the global Vuetify defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:56 +02:00
3ecd4daee1 feat(portal): persist submitter details through draft lifecycle
Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.

- useFormDraft: internal submitterName/submitterEmail refs with setters
  that mark the draft dirty (same debounced-PUT path as field values),
  sessionStorage resume via draft_submitter:{token}, and a
  MISSING_SUBMITTER guard in submitForm so empty fields surface as
  submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
  reads/writes through the composable; onSubmit pre-validates and
  bounces the user back to the Contactgegevens step with a snackbar
  when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
  public_submitter_email per the documented backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:08:13 +02:00
4f2003245f fix(organisation): restore dashboard types dropped during commit split
Add OrganisationMember (met avatar + joined_at), ActivityLogEntry en
OrganisationDashboardStats interfaces die door de pagina en composable
worden gebruikt. Deze hoorden in de dashboard-commit maar vielen uit
door een staging-split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:29:28 +02:00
d4d719a667 feat(organisation): rebuild EditOrganisationDialog with contact fields
Vervang het naam-alleen dialoog door een volledig organisatiegegevens-
formulier: naam, slug (met copy-knop en tooltip), contactpersoon, contact
e-mail, telefoon en website. Slug krijgt een regex-validator; e-mail en
URL alleen gevalideerd wanneer ingevuld. Server-side validatiefouten per
veld getoond.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:28:04 +02:00
027c5dac4e feat(organisation): expand /organisation page to full dashboard
Replace the minimal placeholder with a dashboard: header + edit action,
drie stat-tegels (Leden / Evenementen / Personen — de eerste twee
clickable), organisatiegegevens + leden-top-5 infokaarten en een recente-
activiteit lijst. Nieuwe TypeScript-types en useOrganisationDashboardStats
composable sluiten aan op de nieuwe backend-endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:27:51 +02:00
80f0b535f5 refactor(settings): restructure sidebar and move danger zone to its own tab
Drop the Algemeen tab together with the Organisatie subheader — organisatie-
gegevens verhuizen naar /organisation. Voeg een GEVAARLIJK-subheader toe met
een Gevaarlijke acties tab, die de bestaande platform-beheerder-notitie bevat
(self-delete blijft buiten scope). Legacy ?tab=algemeen/general redirects
door naar /organisation; default tab valt terug op Crowd Types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:27:45 +02:00
b79ebf5550 feat(organisation): add contact fields to model and API
Add contact_name, contact_email, phone, website columns. Wire the new
fields through the Organisation model, update request validation,
response resource, and the TypeScript Organisation interface. Needed by
the upcoming dashboard + form-builder binding registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:26:44 +02:00
cffc34f627 fix(types): resolve 4 pre-existing vue-tsc errors
- EventMetricCards: type navigateTo's routeName as the literal union
  of the two routes it actually targets (events-id-persons,
  events-id-sections) so the typed router accepts it.
- CreateTimeSlotDialog: type the form ref explicitly so person_type
  is PersonType rather than being inferred as string.
- @layouts/types.ts: relax LayoutConfig.app.title from Lowercase<string>
  to string. The lowercase constraint was a compile-time namespacing
  convention in the Vuexy template with zero runtime effect;
  relaxing it lets the branded "Crewli" title satisfy the type.
2026-04-16 22:45:44 +02:00
4da74d2bd4 feat(members): add /members page for organisation-scoped member management 2026-04-16 22:31:52 +02:00
0ca7c0f20f refactor(members): consolidate Platform Admin + Org members into shared useMembers
- useMembers.ts gains a scope param ('organisation' | 'platform') on list,
  invite, update-role, and remove; endpoints branch accordingly.
- Platform Admin's [id].vue now consumes useMembers via scope='platform';
  deleted the duplicated useInviteOrganisationMember / useRemoveOrganisationMember
  / useUpdateOrganisationMemberRole helpers from useAdmin.ts.
- Deduplicated InviteMemberPayload / UpdateMemberRolePayload / AdminOrganisationMember
  from types/admin.ts; Member is now the canonical type.
- SettingsMembers.vue and EditMemberRoleDialog.vue removed (no remaining imports).
- InviteMemberDialog accepts an optional scope prop and is restricted to the
  two organisation-level roles matching the /members UX.
2026-04-16 22:30:42 +02:00
7695011f4b chore(settings): remove Leden tab from Instellingen sidebar 2026-04-16 22:28:20 +02:00
11924b54bb refactor(nav): promote Leden to top-level menu item 2026-04-16 22:28:04 +02:00
c18323de8e chore(companies): refactor filter row for responsive layout
- Wrap filter row so controls flow to a second line on narrow screens
- Search field now flex-fills available width instead of fixed 300px
- Type select: removed inline label, widened to 240px, prevented
  shrink with flex-shrink-0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:12:21 +02:00
8774fff3e9 refactor(settings): move Verzendlog under new Systeem subheader
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:06:02 +02:00
dac6aa4c30 fix: add password constraint validation to all password-set/change forms
Login forms correctly only check for empty fields (no password
constraints needed). But password-reset, password-set, and
password-change forms now enforce constraints client-side:

- App reset-password: add PasswordRequirements component,
  confirmation mismatch check, canSubmit guard, disabled button
- Portal wachtwoord-resetten: add canSubmit guard, confirmation
  check, disabled button (PasswordRequirements was rendered but
  not enforced)
- App SecurityTab (change password): replace static requirements
  list with interactive PasswordRequirements, add canSubmit guard

Also created PasswordRequirements.vue component for the organizer
app (portal already had one).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:58:26 +02:00
824b28897e fix: don't show success on validation error in forgot-password forms
The catch-all error handler (for anti-email-enumeration) was also
swallowing 422 validation errors, making it appear that a reset
email was sent even for empty or invalid input. Now 422 responses
are excluded from the catch — the user stays on the form so the
field-level validation messages remain visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:53:03 +02:00
e5fdb3efb1 fix: add client-side validation to forgot-password forms
Both the organizer app and portal forgot-password pages now
validate the email field before submission: required + email
format check. Backend already validated this, but empty or
malformed emails were being sent to the API unnecessarily.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:51:01 +02:00
b7473a68e1 fix: add client-side validation to portal login form
Add requiredValidator and emailValidator rules to the portal login
form, matching the organizer app login. Empty fields and invalid
email format are now caught before the API call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 20:47:47 +02:00
b647d2827a fix: compact options layout, consistent ImageUploadField across app
- Replace card-based multi-line options with compact single-line rows
  (grip + label + description + delete all on one row)
- Standardize event registration appearance page on ImageUploadField
  (was VFileInput + manual preview, now consistent with email branding)
- Fix EmailBrandingTab logoUrl ref to properly handle null from
  ImageUploadField, ensuring existing image preview works on page load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:15:03 +02:00
6a8d21a5b6 feat: registration field polish, multi-category tags, file uploads, Partner icon
- Restructure field editor dialog: move Options section to bottom with
  divider and subheader, fix delete button with flex layout
- Change tag_category (single string) to tag_categories (JSON array)
  supporting multiple category selection in tag picker fields
- Portal tag picker now groups tags by category with subheaders
- Add generic file upload endpoint (FileUploadService + UploadController)
- Replace email branding logo URL text field with ImageUploadField
- Update Partner crowd type default icon to tabler-affiliate
- Apply changes consistently to both field and template dialogs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:03:49 +02:00
d57dcdb616 feat: HEADING field type for registration forms — replace section property with structural field
Replace the per-field `section` text property with a dedicated HEADING field type that
organizers add as a separate block for visual grouping. Also fixes duplicate heading bug
on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and
adds grouped field type dropdown with structure/input categories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:40:41 +02:00
9718e27029 feat: registration form field display_width and option descriptions
Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.

- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:46:36 +02:00
c4a23b6763 feat: passwordless registration — defer account creation to approval
Removes password from the volunteer registration form. Account
creation is now deferred to the approval step:

Backend:
- Registration creates Person without User (user_id=null)
- On approval, system finds or creates User by person.email
- New accounts get a "set password" email with activation link
- Existing accounts get a portal link email
- Added registration_source column to persons (self/organizer)
- Fuzzy name matching skipped for self-registered persons
- person.email is always source of truth for account linking

Frontend:
- Registration form no longer collects password
- Email check shows info alert with login suggestion
- New wachtwoord-instellen.vue page for account activation
- PasswordRequirements.vue component (reused on reset page)
- Success page updated with activation messaging

Tests: 837 passed (all updated for new flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 03:27:47 +02:00
0221e7f6d3 fix: move impersonation banner inside layout content flow
Replace position:fixed VSystemBar + fragile :deep() CSS overrides
with a normal-flow div inside the Vuexy content area. The banner
renders in VerticalNavLayout's default slot (layout-page-content)
so it sits naturally below the navbar without fighting the layout
system. Sidebar and navbar are no longer affected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 03:17:13 +02:00
dc886fed46 fix: impersonation banner still overlapping navbar
The previous :deep() overrides had equal specificity to Vuexy's
unscoped styles in VerticalNavLayout.vue. Since child component
styles are injected after parent styles, Vuexy's inset-block-start: 0
won by source order. Add !important and simplify the navbar selector
to target .layout-navbar directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 03:14:01 +02:00
89645eab60 fix: impersonation banner overlapping sidebar and navbar
The previous paddingTop on a wrapper div didn't affect the Vuexy
layout's fixed-position sidebar or sticky navbar. Replace with
scoped :deep() CSS overrides that shift both elements down 48px
when impersonation is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:58:27 +02:00
fcab30e5e8 fix: portal shows stale events from localStorage after user_id unlinked
The portal store merged events from the API with localStorage events
without ever pruning stale entries. When /auth/me returned empty
portal_events (e.g. after a person's user_id was cleared), localStorage
events persisted, causing "registratie niet ophalen" when /portal/me
correctly returned 404.

Now when /auth/me succeeds, API data is the source of truth — stored
events not confirmed by the API are dropped. localStorage fallback is
only used when the API call fails (network error).

Also adds an end-to-end test covering the full register → approve →
portal/me flow including festival hierarchy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 02:54:36 +02:00