Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ tests/.auth/
/before-*.png
/after-*.png
/final-*.png
/nav-*.png

# next.js
/.next/
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -780,4 +780,4 @@ npm run test:e2e # Run Playwright tests (168 tests)

---

**Last Updated**: 2026-02-23
**Last Updated**: 2026-07-16
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-domain>/api/cron/notifications
```

</details>

---
Expand Down
Binary file removed nav-admin-desktop-1280.png
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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")
);
Original file line number Diff line number Diff line change
@@ -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");
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ model Placement {
@@index([startDate, endDate])
@@index([residentId])
@@index([housingUnitId])
@@index([compatibilityScore])
}

enum PlacementStatus {
Expand Down Expand Up @@ -410,6 +411,7 @@ model CompatibilityAssessment {

@@unique([residentId, comparedWithId])
@@index([overallScore])
@@index([comparedWithId])
}

// =============================================================================
Expand Down Expand Up @@ -465,6 +467,7 @@ model Incident {
@@index([reportedById])
@@index([subjectId])
@@index([nextFollowUpDate])
@@index([housingUnitId, category, date])
}

model IncidentFollowUp {
Expand Down
2 changes: 2 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 29 additions & 19 deletions src/app/(admin)/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 },
}),
])

Expand All @@ -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
Expand Down
38 changes: 35 additions & 3 deletions src/app/(admin)/housing/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/app/(admin)/housing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -90,7 +92,7 @@ export default async function HousingListPage({ searchParams }: Props) {
description={`${stats.visible} sichtbar · ${stats.occupiedBeds}/${stats.totalBeds} Betten belegt`}
actions={
<ButtonLink href="/housing/new">
{PAGE_TITLES.newHousing}
{ACTION_LABELS.newHousing}
</ButtonLink>
}
/>
Expand Down
42 changes: 17 additions & 25 deletions src/app/(admin)/incidents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand Down Expand Up @@ -92,40 +92,32 @@ export default async function IncidentDetailPage({ params, searchParams }: Props
<SuccessToast
triggers={[
{ param: 'resolved', message: INCIDENT_DETAIL_LABELS.markedResolved },
{ param: 'created', message: INCIDENT_DETAIL_LABELS.created },
]}
/>
{/* Header */}
<div className="mb-6">
<Link
href="/incidents"
className="inline-flex items-center min-h-[44px] px-1 -ml-1 text-sm text-aoz-primary hover:underline"
>
{INCIDENT_DETAIL_LABELS.backLink}
</Link>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mt-2">
<div className="flex items-center gap-4">
<PageHeader
backHref="/incidents"
backLabel={INCIDENT_DETAIL_LABELS.backLink.replace(/^← /, '')}
leading={
<span className="text-3xl" role="img" aria-label={getLabel(INCIDENT_CATEGORY_LABELS, incident.category)}>
{INCIDENT_CATEGORY_ICONS[incident.category] || '💬'}
</span>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-ui-text">
{getLabel(INCIDENT_TYPE_LABELS, incident.type)}
</h1>
<p className="text-ui-muted">
{getLabel(INCIDENT_CATEGORY_LABELS, incident.category)} ·{' '}
{getLabel(INCIDENT_SEVERITY_LABELS, incident.severity)} ·{' '}
{formatDate(incident.date)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{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 ? (
<span className="badge badge-active">{INCIDENT_RESOLVED_LABELS.resolved}</span>
) : (
<span className="badge badge-pending">{INCIDENT_RESOLVED_LABELS.open}</span>
)}
</div>
</div>
)
}
/>
</div>

{/* Follow-up Reminder Banner */}
Expand Down
Loading
Loading