diff --git a/.gitignore b/.gitignore index f2ed4c34..8c0e0656 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ tests/.auth/ /before-*.png /after-*.png /final-*.png +/nav-*.png # next.js /.next/ diff --git a/CLAUDE.md b/CLAUDE.md index 657b9db2..28582480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -651,7 +651,7 @@ model User { ## Testing Strategy -### Unit Tests (Jest) — 2025 tests, 105 suites +### Unit Tests (Jest) — 2069 tests, 106 suites Representative coverage by area (not an exhaustive suite list): @@ -667,7 +667,7 @@ Representative coverage by area (not an exhaustive suite list): | UI components | Dialogs (a11y), filters, BedGrid, style utilities | | Config | Labels, formatting, factor config | -### E2E Tests (Playwright) — 168 tests, 18 specs +### E2E Tests (Playwright) — 192 tests, 18 specs - Auth flow (code-based login) - Resident creation @@ -718,8 +718,8 @@ npm run prisma:migrate # Run pending migrations (production) npm run prisma:push # Push schema changes (development only) npm run prisma:studio # Database browser npm run prisma:seed # Seed demo data -npm run test # Run Jest tests (2025 tests) -npm run test:e2e # Run Playwright tests (168 tests) +npm run test # Run Jest tests (2069 tests) +npm run test:e2e # Run Playwright tests (192 tests) ``` ### Key Files @@ -780,4 +780,4 @@ npm run test:e2e # Run Playwright tests (168 tests) --- -**Last Updated**: 2026-02-23 +**Last Updated**: 2026-07-16 diff --git a/README.md b/README.md index d999ea6d..4476c045 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,16 @@ pnpm dev | `JWT_SECRET` | Session signing key | | `NEXTAUTH_URL` | Application URL | +### Scheduled Jobs (self-hosted) + +The daily staff-notification job (`GET /api/cron/notifications`, guarded by +`CRON_SECRET`) is **not scheduled by the app** — the former Vercel cron was +removed with the move to self-hosting. Schedule it on the host, e.g. crontab: + +```cron +0 8 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https:///api/cron/notifications +``` + --- diff --git a/nav-admin-desktop-1280.png b/nav-admin-desktop-1280.png deleted file mode 100644 index 91c01895..00000000 Binary files a/nav-admin-desktop-1280.png and /dev/null differ diff --git a/prisma/migrations/20260223_simplify_roles_admin_only/migration.sql b/prisma/migrations/20260223_simplify_roles_admin_only/migration.sql index b1817838..83ac2d67 100644 --- a/prisma/migrations/20260223_simplify_roles_admin_only/migration.sql +++ b/prisma/migrations/20260223_simplify_roles_admin_only/migration.sql @@ -1,11 +1,12 @@ -- Migrate all existing users to ADMIN role UPDATE "User" SET "role" = 'ADMIN' WHERE "role" != 'ADMIN'; --- Update the default value -ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'ADMIN'; - --- Remove old enum values (PostgreSQL: recreate enum) +-- Remove old enum values (PostgreSQL: recreate enum). +-- The default must be dropped before the column type changes — Postgres +-- cannot cast a default expression to the new enum automatically. +ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT; ALTER TYPE "StaffRole" RENAME TO "StaffRole_old"; CREATE TYPE "StaffRole" AS ENUM ('ADMIN'); ALTER TABLE "User" ALTER COLUMN "role" TYPE "StaffRole" USING "role"::text::"StaffRole"; DROP TYPE "StaffRole_old"; +ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'ADMIN'; diff --git a/prisma/migrations/20260716000000_add_mediation_minutes_and_system_config/migration.sql b/prisma/migrations/20260716000000_add_mediation_minutes_and_system_config/migration.sql new file mode 100644 index 00000000..ca41715c --- /dev/null +++ b/prisma/migrations/20260716000000_add_mediation_minutes_and_system_config/migration.sql @@ -0,0 +1,18 @@ +-- Catch-up migration: these objects existed in schema.prisma but were never +-- captured in a migration (applied to some environments via `db push`). +-- IF NOT EXISTS guards keep this safe on databases that already have them. + +-- AlterTable +ALTER TABLE "Incident" ADD COLUMN IF NOT EXISTS "mediationMinutes" INTEGER; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "SystemConfig" ( + "id" TEXT NOT NULL DEFAULT 'singleton', + "updatedAt" TIMESTAMP(3) NOT NULL, + "pilotBaselineIncidentsPerMonth" DOUBLE PRECISION, + "pilotBaselineRelocationsPerMonth" DOUBLE PRECISION, + "pilotBaselineMediationHoursPerWeek" DOUBLE PRECISION, + "pilotStartDate" TIMESTAMP(3), + + CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20260716010000_add_query_indexes/migration.sql b/prisma/migrations/20260716010000_add_query_indexes/migration.sql new file mode 100644 index 00000000..940e6d78 --- /dev/null +++ b/prisma/migrations/20260716010000_add_query_indexes/migration.sql @@ -0,0 +1,8 @@ +-- CreateIndex +CREATE INDEX "CompatibilityAssessment_comparedWithId_idx" ON "CompatibilityAssessment"("comparedWithId"); + +-- CreateIndex +CREATE INDEX "Incident_housingUnitId_category_date_idx" ON "Incident"("housingUnitId", "category", "date"); + +-- CreateIndex +CREATE INDEX "Placement_compatibilityScore_idx" ON "Placement"("compatibilityScore"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a6cfc3f7..017ab61f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -365,6 +365,7 @@ model Placement { @@index([startDate, endDate]) @@index([residentId]) @@index([housingUnitId]) + @@index([compatibilityScore]) } enum PlacementStatus { @@ -410,6 +411,7 @@ model CompatibilityAssessment { @@unique([residentId, comparedWithId]) @@index([overallScore]) + @@index([comparedWithId]) } // ============================================================================= @@ -465,6 +467,7 @@ model Incident { @@index([reportedById]) @@index([subjectId]) @@index([nextFollowUpDate]) + @@index([housingUnitId, category, date]) } model IncidentFollowUp { diff --git a/prisma/seed.ts b/prisma/seed.ts index 26a6c2e1..cef03acf 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1433,6 +1433,8 @@ async function main() { supportLevel: 'STANDARD', status: 'ACTIVE', notes: 'Vor kurzem angekommen, wartet auf Platzierung', + // Completed portal preferences — enables the housing browse page (/portal/housing) + preferencesCompletedAt: new Date(), }, }), // RES-022: Success story in ZH-001 diff --git a/src/app/(admin)/analytics/page.tsx b/src/app/(admin)/analytics/page.tsx index b1aeacc6..dfe776ba 100644 --- a/src/app/(admin)/analytics/page.tsx +++ b/src/app/(admin)/analytics/page.tsx @@ -37,38 +37,47 @@ export default async function AnalyticsPage({ searchParams }: Props) { const ninetyDaysAgo = getDateDaysAgo(90) const [ - residents, units, placements, recentPlacements, recentIncidents, checkIns, ] = await Promise.all([ - prisma.resident.findMany({ - where: { status: { in: ['ACTIVE', 'PLACED'] } }, - }), + // Only bed totals and active-placement counts are read prisma.housingUnit.findMany({ - include: { placements: { where: { status: 'ACTIVE' } } }, + select: { + totalBeds: true, + _count: { select: { placements: { where: { status: 'ACTIVE' } } } }, + }, }), + // Overdue check-in calculation needs start date, support level and last check-in only prisma.placement.findMany({ where: { status: 'ACTIVE' }, - include: { - resident: true, - housingUnit: true, + select: { + startDate: true, + resident: { select: { supportLevel: true } }, checkIns: { orderBy: { createdAt: 'desc' }, take: 1, + select: { createdAt: true }, }, }, }), + // Exactly the fields RecentPlacementsTable renders prisma.placement.findMany({ where: { startDate: { gte: ninetyDaysAgo } }, - include: { - housingUnit: true, - resident: true, + select: { + id: true, + startDate: true, + status: true, + residentId: true, + housingUnitId: true, + resident: { select: { code: true, supportLevel: true } }, + housingUnit: { select: { code: true } }, checkIns: { orderBy: { createdAt: 'desc' }, take: 1, + select: { createdAt: true, overallSatisfaction: true }, }, }, orderBy: { startDate: 'desc' }, @@ -78,16 +87,17 @@ export default async function AnalyticsPage({ searchParams }: Props) { date: { gte: periodStart }, category: 'INTERPERSONAL', // Only conflicts, not maintenance }, - include: { housingUnit: true }, + select: { + type: true, + resolvedAt: true, + housingUnitId: true, + housingUnit: { select: { id: true, code: true, address: true } }, + }, }), + // Only satisfaction ratings are aggregated prisma.satisfactionCheckIn.findMany({ where: { createdAt: { gte: periodStart } }, - include: { - placement: { - include: { resident: true, housingUnit: true }, - }, - }, - orderBy: { createdAt: 'desc' }, + select: { overallSatisfaction: true }, }), ]) @@ -109,7 +119,7 @@ export default async function AnalyticsPage({ searchParams }: Props) { // Calculate metrics const totalBeds = units.reduce((sum, u) => sum + u.totalBeds, 0) - const occupiedBeds = units.reduce((sum, u) => sum + u.placements.length, 0) + const occupiedBeds = units.reduce((sum, u) => sum + u._count.placements, 0) const occupancyRate = totalBeds > 0 ? Math.round((occupiedBeds / totalBeds) * 100) : 0 // Check-in status - find overdue diff --git a/src/app/(admin)/housing/[id]/page.tsx b/src/app/(admin)/housing/[id]/page.tsx index 65b4bb16..ca8e1733 100644 --- a/src/app/(admin)/housing/[id]/page.tsx +++ b/src/app/(admin)/housing/[id]/page.tsx @@ -146,13 +146,45 @@ export default async function HousingDetailPage({ params }: Props) { const hasAvailableSpace = unit.placements.length < unit.totalBeds if (hasAvailableSpace) { - // Get unplaced residents - const unplacedResidents = await prisma.resident.findMany({ + // Get unplaced residents — narrow select covering exactly the columns + // read downstream: toResidentProfile (see lib/compatibility/convert.ts), + // getUnitFitConcerns and the ResidentSummary card fields + const unplacedResidents = (await prisma.resident.findMany({ where: { status: 'ACTIVE', placements: { none: { status: 'ACTIVE' } }, }, - }) + select: { + id: true, + code: true, + ageRange: true, + gender: true, + familyStatus: true, + sleepSchedule: true, + noiseTolerance: true, + cleanlinessLevel: true, + guestTolerance: true, + socialStyle: true, + languages: true, + culturalRegion: true, + conflictStyle: true, + smokingStatus: true, + dietaryNeeds: true, + mobilityNeeds: true, + medicalEquipment: true, + petTolerance: true, + sharedBathroom: true, + sharedKitchen: true, + privacyNeed: true, + choresContribution: true, + recyclingKnowledge: true, + roomSharingStatus: true, + hasNightDisturbances: true, + needsQuietEnvironment: true, + hasSleepEquipment: true, + supportLevel: true, + }, + })) as Resident[] if (unplacedResidents.length > 0) { // Calculate apartment profile from current residents diff --git a/src/app/(admin)/housing/page.tsx b/src/app/(admin)/housing/page.tsx index 0360f6a0..8e313637 100644 --- a/src/app/(admin)/housing/page.tsx +++ b/src/app/(admin)/housing/page.tsx @@ -2,9 +2,10 @@ import type { Metadata } from 'next' import { prisma } from '@/lib/db' import { StatCard } from '@/components/ui/Card' import { getDateDaysAgo } from '@/lib/utils' +import { QUERY_LIMITS } from '@/lib/config/thresholds' export const metadata: Metadata = { title: 'Unterkünfte' } -import { EMPTY_STATE_LABELS, UI_LABELS, HOUSING_STATUS_LABELS, HOUSING_STAT_LABELS, PAGE_TITLES, HOUSING_LIST_LABELS } from '@/lib/constants' +import { ACTION_LABELS, EMPTY_STATE_LABELS, UI_LABELS, HOUSING_STATUS_LABELS, HOUSING_STAT_LABELS, PAGE_TITLES, HOUSING_LIST_LABELS } from '@/lib/constants' import { HousingList } from '@/components/housing/HousingList' import { TabLink } from '@/components/ui/Tabs' import { ButtonLink } from '@/components/ui/Button' @@ -57,6 +58,7 @@ export default async function HousingListPage({ searchParams }: Props) { }, }, orderBy: { code: 'asc' }, + take: QUERY_LIMITS.pageList, }), // Unfiltered for tab counts and stats prisma.housingUnit.findMany({ @@ -90,7 +92,7 @@ export default async function HousingListPage({ searchParams }: Props) { description={`${stats.visible} sichtbar · ${stats.occupiedBeds}/${stats.totalBeds} Betten belegt`} actions={ - {PAGE_TITLES.newHousing} + {ACTION_LABELS.newHousing} } /> diff --git a/src/app/(admin)/incidents/[id]/page.tsx b/src/app/(admin)/incidents/[id]/page.tsx index 586c203d..d6a2849f 100644 --- a/src/app/(admin)/incidents/[id]/page.tsx +++ b/src/app/(admin)/incidents/[id]/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next' import { prisma } from '@/lib/db' import { notFound } from 'next/navigation' -import Link from 'next/link' import { clearFollowUpReminder } from '@/lib/actions' import { INCIDENT_TYPE_LABELS, @@ -26,6 +25,7 @@ import { formatDate, } from '@/lib/utils' import { SuccessToast } from '@/components/ui/SuccessToast' +import { PageHeader } from '@/components/ui/Page' import { FollowUpTimeline } from '@/components/incidents/FollowUpTimeline' import { FollowUpForm } from '@/components/incidents/FollowUpForm' import { IncidentSidebar } from '@/components/incidents/IncidentSidebar' @@ -92,40 +92,32 @@ export default async function IncidentDetailPage({ params, searchParams }: Props {/* Header */}
- - {INCIDENT_DETAIL_LABELS.backLink} - -
-
+ {INCIDENT_CATEGORY_ICONS[incident.category] || '💬'} -
-

- {getLabel(INCIDENT_TYPE_LABELS, incident.type)} -

-

- {getLabel(INCIDENT_CATEGORY_LABELS, incident.category)} ·{' '} - {getLabel(INCIDENT_SEVERITY_LABELS, incident.severity)} ·{' '} - {formatDate(incident.date)} -

-
-
-
- {incident.resolvedAt ? ( + } + title={getLabel(INCIDENT_TYPE_LABELS, incident.type)} + description={`${getLabel(INCIDENT_CATEGORY_LABELS, incident.category)} · ${getLabel( + INCIDENT_SEVERITY_LABELS, + incident.severity + )} · ${formatDate(incident.date)}`} + actions={ + incident.resolvedAt ? ( {INCIDENT_RESOLVED_LABELS.resolved} ) : ( {INCIDENT_RESOLVED_LABELS.open} - )} -
-
+ ) + } + />
{/* Follow-up Reminder Banner */} diff --git a/src/app/(admin)/incidents/page.tsx b/src/app/(admin)/incidents/page.tsx index bb960990..979fc27e 100644 --- a/src/app/(admin)/incidents/page.tsx +++ b/src/app/(admin)/incidents/page.tsx @@ -56,14 +56,15 @@ export default async function IncidentsListPage({ searchParams }: Props) { resolution: true, nextFollowUpDate: true, mediationMinutes: true, + housingUnitId: true, housingUnit: { select: { code: true }, }, reportedBy: { - select: { code: true }, + select: { id: true, code: true }, }, subject: { - select: { code: true }, + select: { id: true, code: true }, }, _count: { select: { followUps: true }, @@ -185,7 +186,7 @@ export default async function IncidentsListPage({ searchParams }: Props) { {/* Category Tabs - Note: Maintenance requests have their own page (/maintenance) */}
-
+
{categoryIcon}
-

- {getLabel(INCIDENT_TYPE_LABELS, incident.type)} +

+ + {getLabel(INCIDENT_TYPE_LABELS, incident.type)} +

- + + {incident.reportedBy && ( - + )} {incident.subject && ( - + )} {formatRelativeDate(incident.date)}
@@ -338,7 +349,7 @@ function IncidentRow({ incident }: { incident: IncidentRowData }) {

)} - +
) } diff --git a/src/app/(admin)/maintenance/[id]/page.tsx b/src/app/(admin)/maintenance/[id]/page.tsx index 8cce5a52..837c0843 100644 --- a/src/app/(admin)/maintenance/[id]/page.tsx +++ b/src/app/(admin)/maintenance/[id]/page.tsx @@ -2,9 +2,11 @@ import type { Metadata } from 'next' import { prisma } from '@/lib/db' import { notFound } from 'next/navigation' import Link from 'next/link' -import { updateMaintenanceStatus } from '@/lib/actions' +import { updateMaintenanceStatus, assignMaintenanceRequest, getMaintenanceAssignees } from '@/lib/actions' +import { getCurrentUser } from '@/lib/auth' import { FormValidationUX } from '@/components/forms' import { SubmitButton } from '@/components/ui' +import { PageHeader } from '@/components/ui/Page' import { MAINTENANCE_CATEGORY_LABELS, MAINTENANCE_CATEGORY_ICONS, @@ -32,14 +34,18 @@ interface Props { export default async function MaintenanceDetailPage({ params }: Props) { const { id } = await params - const request = await prisma.maintenanceRequest.findUnique({ - where: { id }, - include: { - housingUnit: true, - spot: true, - reportedBy: { select: { id: true, code: true } }, - }, - }) + const [request, assignees, currentUser] = await Promise.all([ + prisma.maintenanceRequest.findUnique({ + where: { id }, + include: { + housingUnit: true, + spot: true, + reportedBy: { select: { id: true, code: true } }, + }, + }), + getMaintenanceAssignees(), + getCurrentUser(), + ]) if (!request) { notFound() @@ -54,35 +60,25 @@ export default async function MaintenanceDetailPage({ params }: Props) {
{/* Header */}
- - {MAINTENANCE_PAGE_LABELS.backToList} - -
-
- {categoryIcon} -
-

- {request.title} -

-

- {getLabel(MAINTENANCE_CATEGORY_LABELS, request.category)} ·{' '} - {request.housingUnit.code} - {request.location && ` · ${request.location}`} -

-
-
-
- - {getLabel(MAINTENANCE_PRIORITY_LABELS, request.priority)} - - - {getLabel(MAINTENANCE_STATUS_LABELS, request.status)} - -
-
+ {categoryIcon}} + title={request.title} + description={`${getLabel(MAINTENANCE_CATEGORY_LABELS, request.category)} · ${request.housingUnit.code}${ + request.location ? ` · ${request.location}` : '' + }`} + actions={ + <> + + {getLabel(MAINTENANCE_PRIORITY_LABELS, request.priority)} + + + {getLabel(MAINTENANCE_STATUS_LABELS, request.status)} + + + } + />
@@ -123,10 +119,25 @@ export default async function MaintenanceDetailPage({ params }: Props) { + + {assignees.map((name) => ( + + {currentUser && ( + + )}
@@ -170,6 +181,14 @@ export default async function MaintenanceDetailPage({ params }: Props) { {MAINTENANCE_PAGE_LABELS.updateStatusBtn} + {/* One-click self-assignment — separate form (forms cannot nest); + triggered via the form attribute on the button above. */} + {currentUser && ( +
+ + +
+ )}
)} diff --git a/src/app/(admin)/maintenance/page.tsx b/src/app/(admin)/maintenance/page.tsx index 3d98169d..d9edf34d 100644 --- a/src/app/(admin)/maintenance/page.tsx +++ b/src/app/(admin)/maintenance/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from 'next' import { prisma } from '@/lib/db' import Link from 'next/link' -import { updateMaintenanceStatus, assignMaintenanceRequest } from '@/lib/actions' +import { updateMaintenanceStatus, assignMaintenanceRequest, getMaintenanceAssignees } from '@/lib/actions' +import { getCurrentUser } from '@/lib/auth' export const metadata: Metadata = { title: 'Wartungsanfragen' } import { @@ -40,7 +41,7 @@ export default async function MaintenancePage({ searchParams }: Props) { whereClause.status = statusFilter as MaintenanceStatus } - const [requests, statusGroups, urgentActiveCount] = await Promise.all([ + const [requests, statusGroups, urgentActiveCount, assignees, currentUser] = await Promise.all([ prisma.maintenanceRequest.findMany({ where: whereClause, select: { @@ -80,6 +81,10 @@ export default async function MaintenancePage({ searchParams }: Props) { status: { notIn: ['COMPLETED', 'CANCELLED'] }, }, }), + // Known assignee names for the quick-assign datalist + getMaintenanceAssignees(), + // Current staff user for the "Mir zuweisen" one-click assignment + getCurrentUser(), ]) const statusCounts = statusGroups.reduce>((acc, g) => { @@ -138,7 +143,7 @@ export default async function MaintenancePage({ searchParams }: Props) { {/* Status Tabs */}
-
+
) : (
+ + {assignees.map((name) => ( + {requests.map((request) => ( - + ))}
)} @@ -210,7 +220,7 @@ interface RequestRowData { reportedBy: { id: string; code: string } | null } -function RequestRow({ request }: { request: RequestRowData }) { +function RequestRow({ request, currentUserName }: { request: RequestRowData; currentUserName: string | null }) { const categoryIcon = MAINTENANCE_CATEGORY_ICONS[request.category] || '🔧' const priorityClass = MAINTENANCE_PRIORITY_COLORS[request.priority] || '' const statusClass = MAINTENANCE_STATUS_COLORS[request.status] || 'badge' @@ -265,34 +275,52 @@ function RequestRow({ request }: { request: RequestRowData }) { {getLabel(MAINTENANCE_STATUS_LABELS, request.status)} - +
) } -function QuickActions({ request }: { request: Pick }) { +function QuickActions({ + request, + currentUserName, +}: { + request: Pick + currentUserName: string | null +}) { if (request.status === 'COMPLETED' || request.status === 'CANCELLED') { return null } return ( -
+
{request.status === 'OPEN' && ( -
- - - -
+ <> +
+ + + +
+ {currentUserName && ( +
+ + + +
+ )} + )} {(request.status === 'ASSIGNED' || request.status === 'ON_HOLD') && (
diff --git a/src/app/(admin)/matching/page.tsx b/src/app/(admin)/matching/page.tsx index 9bc510df..c00e560a 100644 --- a/src/app/(admin)/matching/page.tsx +++ b/src/app/(admin)/matching/page.tsx @@ -10,7 +10,12 @@ import { calculateApartmentProfile, calculateApartmentFit } from '@/lib/compatib import { toResidentProfile } from '@/lib/compatibility/convert' import { validateScoreForDiscrimination } from '@/lib/compatibility/safeguards' import type { SafeguardWarning } from '@/lib/compatibility/safeguards' -import { calculateUnitMetrics, getSimilarPlacementSuccessRate } from '@/lib/analytics/unit-metrics' +import { + calculateUnitMetricsBatch, + getSimilarPlacementSuccessRateBatch, + type UnitMetrics, + type PlacementSuccessRate, +} from '@/lib/analytics/unit-metrics' import type { Resident } from '@prisma/client' import type { ApartmentConflict } from '@/lib/compatibility/types' import type { @@ -37,12 +42,12 @@ export default async function MatchingPage({ searchParams }: Props) { // All four queries are independent — fetch in parallel const [ unplacedResidents, - placedResidents, + placedResidentRows, totalResidentCount, availableUnits, ]: [ Resident[], - ResidentWithPlacement[], + { id: string; code: string; placements: { housingUnit: { id: string; code: string } }[] }[], number, MatchUnit[], ] = await Promise.all([ @@ -53,15 +58,19 @@ export default async function MatchingPage({ searchParams }: Props) { }, orderBy: { createdAt: 'desc' }, }), + // Narrow select: ResidentSelectorPanel only renders id, code and the + // current unit's code for placed residents prisma.resident.findMany({ where: { status: 'PLACED', placements: { some: { status: 'ACTIVE' } }, }, - include: { + select: { + id: true, + code: true, placements: { where: { status: 'ACTIVE' }, - include: { housingUnit: { select: { id: true, code: true } } }, + select: { housingUnit: { select: { id: true, code: true } } }, take: 1, }, }, @@ -93,6 +102,11 @@ export default async function MatchingPage({ searchParams }: Props) { }), ]) + // ResidentSelectorPanel's prop type expects ResidentWithPlacement; the + // narrow rows above cover every field it actually reads (id, code, + // placements[0].housingUnit.code) + const placedResidents = placedResidentRows as ResidentWithPlacement[] + // If resident is selected, calculate matches let selectedResident: ResidentWithPlacement | null = null let matches: MatchResult[] = [] @@ -147,8 +161,10 @@ export default async function MatchingPage({ searchParams }: Props) { if (foundResident) { const filteredUnits = availableUnits.filter((unit) => unit.placements.length < unit.totalBeds) - // Calculate matches with unit metrics (async) - matches = await Promise.all(filteredUnits.map(async (unit) => { + // Compute apartment profiles/fits first (pure CPU) so the historical + // metrics and success rates can each be fetched in one batched + // round-trip instead of ~5 queries per unit + const unitFits = filteredUnits.map((unit) => { const currentResidents = unit.placements.map((p) => p.resident) // Calculate apartment aggregate profile @@ -163,21 +179,30 @@ export default async function MatchingPage({ searchParams }: Props) { apartmentProfile ) - // Get unit historical metrics - let unitMetrics = null - try { - unitMetrics = await calculateUnitMetrics(unit.id) - } catch (error) { - logger.warn(`Failed to calculate metrics for unit ${unit.id}`, { unitId: unit.id }) - } - - // Get REAL success rate data from database - let realSuccessData = null - try { - realSuccessData = await getSimilarPlacementSuccessRate(apartmentFit.fitScore, 10) - } catch (error) { + return { unit, currentResidents, apartmentProfile, apartmentFit } + }) + + // Unit historical metrics + REAL success rate data — constant number of + // queries for the whole set (units are passed pre-loaded, no re-fetch) + const [metricsByUnitId, successRateByScore] = await Promise.all([ + calculateUnitMetricsBatch( + filteredUnits.map(u => ({ id: u.id, code: u.code, totalBeds: u.totalBeds })) + ).catch((): Map => { + logger.warn('Failed to calculate unit metrics') + return new Map() + }), + getSimilarPlacementSuccessRateBatch( + unitFits.map(f => f.apartmentFit.fitScore), + 10 + ).catch((): Map => { logger.warn('Failed to get success rate data') - } + return new Map() + }), + ]) + + matches = unitFits.map(({ unit, currentResidents, apartmentProfile, apartmentFit }) => { + const unitMetrics = metricsByUnitId.get(unit.id) ?? null + const realSuccessData = successRateByScore.get(apartmentFit.fitScore) ?? null // Calculate compatibility details with current residents const compatibilityDetails: CompatibilityDetail[] = [] @@ -254,7 +279,7 @@ export default async function MatchingPage({ searchParams }: Props) { sharedLanguageCount * 5 - (currentResidents.length === 0 ? 20 : 0) } - })) + }) // Sort matches by score matches = matches.sort((a, b) => a.sortScore - b.sortScore) diff --git a/src/app/(admin)/placements/[id]/checkin/page.tsx b/src/app/(admin)/placements/[id]/checkin/page.tsx index 325c3eeb..27a6f4d2 100644 --- a/src/app/(admin)/placements/[id]/checkin/page.tsx +++ b/src/app/(admin)/placements/[id]/checkin/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { createCheckInFromForm } from '@/lib/actions' import { CHECK_IN_TYPE_LABELS, CHECKIN_FORM_LABELS, UI_LABELS } from '@/lib/constants' import { SubmitButton } from '@/components/ui' +import { PageHeader } from '@/components/ui/Page' import { weeksBetween } from '@/lib/utils' export const metadata: Metadata = { title: 'Check-in' } @@ -47,20 +48,14 @@ export default async function NewCheckInPage({ params }: Props) {
{/* Header */}
- - {CHECKIN_FORM_LABELS.backLink(placement.resident.code)} - -

- {CHECKIN_FORM_LABELS.title} -

-

- {placement.resident.code} in {placement.housingUnit.code} - {placement.spot && ` (${placement.spot.label || placement.spot.code})`} - {' • '}{CHECKIN_FORM_LABELS.week(weeksSinceStart)} -

+
{/* Previous Check-ins Summary */} diff --git a/src/app/(admin)/placements/page.tsx b/src/app/(admin)/placements/page.tsx index 1f5ab6f7..b56cfebc 100644 --- a/src/app/(admin)/placements/page.tsx +++ b/src/app/(admin)/placements/page.tsx @@ -288,7 +288,7 @@ function PlacementRow({ placement }: { placement: PlacementRowData }) { return (
diff --git a/src/app/(admin)/residents/[id]/page.tsx b/src/app/(admin)/residents/[id]/page.tsx index 07d17c83..ea236889 100644 --- a/src/app/(admin)/residents/[id]/page.tsx +++ b/src/app/(admin)/residents/[id]/page.tsx @@ -38,6 +38,7 @@ import { weeksBetween, } from '@/lib/utils' import { SuccessToast } from '@/components/ui/SuccessToast' +import { PageHeader } from '@/components/ui/Page' import { QUERY_LIMITS } from '@/lib/config/thresholds' export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { @@ -247,52 +248,41 @@ export default async function ResidentDetailPage({ params, searchParams }: Props ]} /> {/* Header */} -
-
-
- - {RESIDENT_DETAIL_LABELS.breadcrumb} - - / - {resident.code} -
-
+
+ {resident.code.slice(-3)}
-
-

- {resident.code} -

-

- {getLabel(AGE_RANGE_LABELS, resident.ageRange)} ·{' '} - {getLabel(GENDER_LABELS, resident.gender)} ·{' '} - {getLabel(FAMILY_STATUS_LABELS, resident.familyStatus)} -

-
-
-
-
- - {getLabel(RESIDENT_STATUS_LABELS, resident.status)} - - {currentPlacement && ( - - {RESIDENT_DETAIL_LABELS.transferBtn} - - )} - - {RESIDENT_DETAIL_LABELS.editBtn} - - {!currentPlacement && ( - - {RESIDENT_DETAIL_LABELS.placeBtn} - - )} -
+ } + title={resident.code} + description={`${getLabel(AGE_RANGE_LABELS, resident.ageRange)} · ${getLabel( + GENDER_LABELS, + resident.gender + )} · ${getLabel(FAMILY_STATUS_LABELS, resident.familyStatus)}`} + actions={ + <> + + {getLabel(RESIDENT_STATUS_LABELS, resident.status)} + + {currentPlacement && ( + + {RESIDENT_DETAIL_LABELS.transferBtn} + + )} + + {RESIDENT_DETAIL_LABELS.editBtn} + + {!currentPlacement && ( + + {RESIDENT_DETAIL_LABELS.placeBtn} + + )} + + } + />
diff --git a/src/app/(admin)/residents/new/page.tsx b/src/app/(admin)/residents/new/page.tsx index 4fd75bea..93ee3532 100644 --- a/src/app/(admin)/residents/new/page.tsx +++ b/src/app/(admin)/residents/new/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import Link from 'next/link' import { ResidentFormFields } from '@/components/forms' import { SubmitButton } from '@/components/ui' +import { PageHeader } from '@/components/ui/Page' import { createResident } from '@/lib/actions' import { RESIDENT_NEW_LABELS } from '@/lib/constants' @@ -13,35 +14,32 @@ export default function NewResidentPage() { return (
- - {RESIDENT_NEW_LABELS.backLink} - -

- {RESIDENT_NEW_LABELS.title} -

-

- {RESIDENT_NEW_LABELS.step1Subtitle} -

+
- {/* Process Indicator */} -
-
-
- 1 + {/* Journey Indicator */} +
+
+
+ + {RESIDENT_NEW_LABELS.step1Label}
- {RESIDENT_NEW_LABELS.step1Label} -
-
-
-
- 2 +
+
+ + {RESIDENT_NEW_LABELS.step2Label}
- {RESIDENT_NEW_LABELS.step2Label}
+

{RESIDENT_NEW_LABELS.stepsHint}

diff --git a/src/app/(admin)/residents/page.tsx b/src/app/(admin)/residents/page.tsx index 22af1031..e0060730 100644 --- a/src/app/(admin)/residents/page.tsx +++ b/src/app/(admin)/residents/page.tsx @@ -4,6 +4,7 @@ import { EMPTY_STATE_LABELS, RESIDENT_LIST_LABELS, UI_LABELS, RESIDENT_STATUS_LA export const metadata: Metadata = { title: 'Bewohner' } import { getDateDaysAgo } from '@/lib/utils' +import { QUERY_LIMITS } from '@/lib/config/thresholds' import { StatCard } from '@/components/ui/Card' import { ResidentsList } from '@/components/residents/ResidentsList' import { TabLink } from '@/components/ui/Tabs' @@ -56,6 +57,7 @@ export default async function ResidentsListPage({ searchParams }: Props) { }, }, orderBy: { createdAt: 'desc' }, + take: QUERY_LIMITS.pageList, }), // Aggregate tab counts by status (single query instead of fetching all rows) prisma.resident.groupBy({ diff --git a/src/app/(admin)/settings/page.tsx b/src/app/(admin)/settings/page.tsx index c8087fa7..18d7d30f 100644 --- a/src/app/(admin)/settings/page.tsx +++ b/src/app/(admin)/settings/page.tsx @@ -91,8 +91,9 @@ export default async function SettingsPage() {
- + {PILOT_BASELINE_LABELS.startDateHint}

- + {PILOT_BASELINE_LABELS.incidentsHint}

- + {PILOT_BASELINE_LABELS.relocationsHint}

- + (null) + const [result, setResult] = useState<(ReviewTransferResult & { action: 'approve' | 'deny' }) | null>(null) async function handleAction(action: 'approve' | 'deny') { setLoading(true) @@ -22,7 +26,7 @@ export function TransferActions({ requestId }: TransferActionsProps) { const fn = action === 'approve' ? approveTransferRequest : denyTransferRequest const res = await fn({ requestId, staffNotes: staffNotes || undefined }) - setResult(res) + setResult({ ...res, action }) setLoading(false) if (res.success) { router.refresh() @@ -30,8 +34,21 @@ export function TransferActions({ requestId }: TransferActionsProps) { } if (result?.success) { + const approvedOnly = result.action === 'approve' && !result.executed return ( -

{L.success}

+
+

+ {result.executed ? L.successExecuted : approvedOnly ? L.successApprovedOnly : L.success} +

+ {approvedOnly && ( +

+ {L.completeTransferHint}{' '} + + {L.completeTransferLink} + +

+ )} +
) } @@ -41,26 +58,27 @@ export function TransferActions({ requestId }: TransferActionsProps) { value={staffNotes} onChange={(e) => setStaffNotes(e.target.value)} placeholder={L.notesPlaceholder} + aria-label={L.notesPlaceholder} rows={2} - className="w-full rounded-lg border border-ui-border-strong px-3 py-2 text-sm focus:border-aoz-primary focus:ring-1 focus:ring-aoz-primary resize-none" + className="input resize-none" /> {result?.error && (

{result.error}

)} -
+
diff --git a/src/app/(admin)/transfer-requests/__tests__/TransferActions.test.tsx b/src/app/(admin)/transfer-requests/__tests__/TransferActions.test.tsx index f43202ab..b954cf32 100644 --- a/src/app/(admin)/transfer-requests/__tests__/TransferActions.test.tsx +++ b/src/app/(admin)/transfer-requests/__tests__/TransferActions.test.tsx @@ -20,17 +20,34 @@ jest.mock('@/lib/constants', () => ({ TRANSFER_ACTION_LABELS: { notesPlaceholder: 'Notiz (optional)', approve: 'Genehmigen', + approveAndTransfer: 'Genehmigen & verlegen', deny: 'Ablehnen', processing: 'Wird bearbeitet...', success: 'Erfolgreich bearbeitet', + successExecuted: 'Genehmigt — Verlegung wurde durchgeführt', + successApprovedOnly: 'Genehmigt — Verlegung noch nicht durchgeführt', + completeTransferHint: 'Zieleinheit über die Bewohnerseite wählen und Verlegung abschliessen:', + completeTransferLink: 'Verlegung durchführen', }, })) // --- Helpers --- -function mockSuccess() { - mockApprove.mockResolvedValue({ success: true }) - mockDeny.mockResolvedValue({ success: true }) +function renderActions( + props: Partial<{ requestId: string; residentId: string; hasTargetUnit: boolean }> = {} +) { + return render( + + ) +} + +function mockSuccess(executed = false) { + mockApprove.mockResolvedValue({ success: true, executed }) + mockDeny.mockResolvedValue({ success: true, executed: false }) } function mockError(message = 'Fehler aufgetreten') { @@ -46,17 +63,23 @@ describe('TransferActions', () => { // ── Rendering ─────────────────────────────────────────────────────────── it('renders the notes textarea, approve and deny buttons', () => { - render() + renderActions() expect(screen.getByPlaceholderText('Notiz (optional)')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Genehmigen' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Ablehnen' })).toBeInTheDocument() }) + it('labels the approve button "Genehmigen & verlegen" when a target unit is set', () => { + renderActions({ hasTargetUnit: true }) + expect(screen.getByRole('button', { name: 'Genehmigen & verlegen' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Genehmigen' })).not.toBeInTheDocument() + }) + // ── Approve flow ──────────────────────────────────────────────────────── it('calls approveTransferRequest with requestId when approve is clicked', async () => { mockSuccess() - render() + renderActions({ requestId: 'req-abc' }) fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) @@ -66,7 +89,7 @@ describe('TransferActions', () => { it('includes staffNotes in the approve call when typed', async () => { mockSuccess() - render() + renderActions({ requestId: 'req-abc' }) fireEvent.change(screen.getByPlaceholderText('Notiz (optional)'), { target: { value: 'Genehmigt nach Rücksprache' }, @@ -80,22 +103,49 @@ describe('TransferActions', () => { }) }) - it('shows success message after successful approve', async () => { + it('shows approved-only message after approve without executed transfer', async () => { mockSuccess() - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) await waitFor(() => { - expect(screen.getByRole('status')).toHaveTextContent('Erfolgreich bearbeitet') + expect(screen.getByRole('status')).toHaveTextContent( + 'Genehmigt — Verlegung noch nicht durchgeführt' + ) }) // Form is replaced by success message expect(screen.queryByRole('button', { name: 'Genehmigen' })).not.toBeInTheDocument() }) + it('shows executed message and no follow-up link when the transfer was executed', async () => { + mockSuccess(true) + renderActions({ hasTargetUnit: true }) + + fireEvent.click(screen.getByRole('button', { name: 'Genehmigen & verlegen' })) + + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent( + 'Genehmigt — Verlegung wurde durchgeführt' + ) + }) + expect(screen.queryByRole('link', { name: 'Verlegung durchführen' })).not.toBeInTheDocument() + }) + + it('shows a link to complete the transfer when approval did not execute it', async () => { + mockSuccess(false) + renderActions({ residentId: 'res-42' }) + + fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) + + await waitFor(() => screen.getByRole('status')) + const link = screen.getByRole('link', { name: 'Verlegung durchführen' }) + expect(link).toHaveAttribute('href', '/residents/res-42') + }) + it('calls router.refresh() after successful approve', async () => { mockSuccess() - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) @@ -106,7 +156,7 @@ describe('TransferActions', () => { it('calls denyTransferRequest with requestId when deny is clicked', async () => { mockSuccess() - render() + renderActions({ requestId: 'req-xyz' }) fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' })) @@ -114,20 +164,22 @@ describe('TransferActions', () => { expect(mockDeny).toHaveBeenCalledWith({ requestId: 'req-xyz', staffNotes: undefined }) }) - it('shows success message after successful deny', async () => { + it('shows success message after successful deny without the complete-transfer link', async () => { mockSuccess() - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' })) await waitFor(() => { expect(screen.getByRole('status')).toHaveTextContent('Erfolgreich bearbeitet') }) + // A denial must never invite staff to execute the transfer + expect(screen.queryByRole('link', { name: 'Verlegung durchführen' })).not.toBeInTheDocument() }) it('calls router.refresh() after successful deny', async () => { mockSuccess() - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' })) @@ -140,7 +192,7 @@ describe('TransferActions', () => { let resolve: (v: { success: boolean }) => void mockApprove.mockReturnValue(new Promise((r) => { resolve = r })) - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) await waitFor(() => { @@ -156,7 +208,7 @@ describe('TransferActions', () => { it('shows error alert and keeps the form visible on failure', async () => { mockError('Anfrage bereits bearbeitet') - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) @@ -169,7 +221,7 @@ describe('TransferActions', () => { it('does NOT call router.refresh() on failure', async () => { mockError() - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) @@ -180,7 +232,7 @@ describe('TransferActions', () => { it('clears a previous error when a new action is started', async () => { // First attempt fails mockApprove.mockResolvedValueOnce({ success: false, error: 'Erster Fehler' }) - render() + renderActions() fireEvent.click(screen.getByRole('button', { name: 'Genehmigen' })) await waitFor(() => screen.getByRole('alert')) diff --git a/src/app/(admin)/transfer-requests/page.tsx b/src/app/(admin)/transfer-requests/page.tsx index e47010ae..c2efbd54 100644 --- a/src/app/(admin)/transfer-requests/page.tsx +++ b/src/app/(admin)/transfer-requests/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import Link from 'next/link' import { getTransferRequests } from '@/lib/actions/transfers' import { TabLink } from '@/components/ui/Tabs' import { PageHeader } from '@/components/ui/Page' @@ -17,15 +18,15 @@ export default async function TransferRequestsPage({ searchParams }: Props) { const params = await searchParams const statusFilter = params.status || 'PENDING' - const [requests, allRequests] = await Promise.all([ - getTransferRequests(statusFilter === 'ALL' ? undefined : statusFilter), - getTransferRequests(), - ]) + const allRequests = await getTransferRequests() + const requests = + statusFilter === 'ALL' ? allRequests : allRequests.filter((r) => r.status === statusFilter) const counts = { all: allRequests.length, pending: allRequests.filter(r => r.status === 'PENDING').length, approved: allRequests.filter(r => r.status === 'APPROVED').length, denied: allRequests.filter(r => r.status === 'DENIED').length, + completed: allRequests.filter(r => r.status === 'COMPLETED').length, } return ( @@ -36,7 +37,7 @@ export default async function TransferRequestsPage({ searchParams }: Props) { {/* Status Tabs */}
-
+
+

- {request.resident.code} + + {request.resident.code} +

{STATUS_LABEL[request.status] || request.status} @@ -125,10 +138,26 @@ function TransferRequestCard({ request }: { request: TransferRequestData }) {

{formatRelativeDate(request.createdAt)} {request.currentPlacement?.housingUnit && ( - <> · {TRANSFER_ACTION_LABELS.fromUnit} {request.currentPlacement.housingUnit.code} + <> + {' · '}{TRANSFER_ACTION_LABELS.fromUnit}{' '} + + {request.currentPlacement.housingUnit.code} + + )} {request.targetUnit && ( - <> · {TRANSFER_ACTION_LABELS.toUnit} {request.targetUnit.code} + <> + {' · '}{TRANSFER_ACTION_LABELS.toUnit}{' '} + + {request.targetUnit.code} + + )}

@@ -143,7 +172,25 @@ function TransferRequestCard({ request }: { request: TransferRequestData }) { )} {request.status === 'PENDING' && ( - + + )} + + {request.status === 'APPROVED' && ( +
+

+ {TRANSFER_ACTION_LABELS.completeTransferHint}{' '} + + {TRANSFER_ACTION_LABELS.completeTransferLink} + +

+
)}
) diff --git a/src/app/api/portal/__tests__/preferences.test.ts b/src/app/api/portal/__tests__/preferences.test.ts index f1c035ed..38aff10a 100644 --- a/src/app/api/portal/__tests__/preferences.test.ts +++ b/src/app/api/portal/__tests__/preferences.test.ts @@ -43,6 +43,7 @@ jest.mock('@/lib/logger', () => ({ // --- Import after mocks --- import { POST } from '../preferences/route' +import { parseRoommatePreferences } from '@/components/portal/roommate-preferences' // --- Helpers --- @@ -159,6 +160,53 @@ describe('POST /api/portal/preferences', () => { expect(updateCall.data.roommatePreferences).toContain('Ruhige Person') }) + test('serializes roommatePreferences in the parseable labeled-line format', async () => { + const resident = { id: 'res-3', code: 'RES-003' } + mockCookieGet.mockReturnValue({ value: 'RES-003' }) + mockFindUnique.mockResolvedValue(resident) + mockUpdate.mockResolvedValue(resident) + + const prefsWithRoommate = { + ...VALID_PREFS, + preferredAgeRange: 'SIMILAR', + culturalPreference: 'SAME_REGION', + additionalPreferences: 'Ich arbeite Nachtschicht', + } + + const req = createPreferencesRequest(prefsWithRoommate) + const res = await POST(req) + + expect(res.status).toBe(200) + + const updateCall = mockUpdate.mock.calls[0][0] + // Human-readable German labels, one labeled line per part + expect(updateCall.data.roommatePreferences).toBe( + 'Altersgruppe: Ähnliches Alter wie ich\n' + + 'Kultur: Aus meiner Region\n' + + 'Wünsche: Ich arbeite Nachtschicht' + ) + // Stored text must parse back to the submitted form values (round-trip) + expect(parseRoommatePreferences(updateCall.data.roommatePreferences)).toEqual({ + preferredAgeRange: 'SIMILAR', + culturalPreference: 'SAME_REGION', + additionalPreferences: 'Ich arbeite Nachtschicht', + }) + }) + + test('stores null roommatePreferences when all optional fields are empty', async () => { + const resident = { id: 'res-4', code: 'RES-004' } + mockCookieGet.mockReturnValue({ value: 'RES-004' }) + mockFindUnique.mockResolvedValue(resident) + mockUpdate.mockResolvedValue(resident) + + const req = createPreferencesRequest(VALID_PREFS) + const res = await POST(req) + + expect(res.status).toBe(200) + const updateCall = mockUpdate.mock.calls[0][0] + expect(updateCall.data.roommatePreferences).toBeNull() + }) + test('returns 400 on validation error (invalid sleepSchedule)', async () => { mockCookieGet.mockReturnValue({ value: 'RES-001' }) mockFindUnique.mockResolvedValue({ id: 'res-1', code: 'RES-001' }) diff --git a/src/app/api/portal/preferences/route.ts b/src/app/api/portal/preferences/route.ts index e37121c8..e97b3f7c 100644 --- a/src/app/api/portal/preferences/route.ts +++ b/src/app/api/portal/preferences/route.ts @@ -5,6 +5,7 @@ import { portalPreferencesSchema, validateFormData, ValidationError } from '@/li import { logger } from '@/lib/logger' import { ERROR_MESSAGES } from '@/lib/constants/error-messages' import { getResidentCookie } from '@/lib/portal-auth' +import { serializeRoommatePreferences } from '@/components/portal/roommate-preferences' export async function POST(request: NextRequest) { const residentCode = await getResidentCookie() @@ -32,12 +33,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: ERROR_MESSAGES.INVALID_INPUT }, { status: 400 }) } - // Build roommate preferences text (stored in dedicated column, NOT notes) - const roommatePrefsText = [ - data.preferredAgeRange ? `Altersgruppe: ${data.preferredAgeRange}` : null, - data.culturalPreference ? `Kultur: ${data.culturalPreference}` : null, - data.additionalPreferences || null, - ].filter(Boolean).join('. ') || null + // Build roommate preferences text (stored in dedicated column, NOT notes). + // Serialized as labeled German lines so staff can read it and the portal + // form can parse it back into its fields (round-trip). + const roommatePrefsText = serializeRoommatePreferences({ + preferredAgeRange: data.preferredAgeRange, + culturalPreference: data.culturalPreference, + additionalPreferences: data.additionalPreferences, + }) try { await prisma.resident.update({ diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 8588e5ad..25253306 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -163,7 +163,8 @@ function LoginForm() { autoFocus={!searchParams.get('code')} className="input font-mono text-center text-lg tracking-wider" /> -

{LOGIN_LABELS.codeHint}

+

{LOGIN_LABELS.residentHint}

+

{LOGIN_LABELS.staffHint}