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