Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/db/real/aoz-team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const AOZ_TEAM: readonly RealStaffSeed[] = [
note: 'Betreuerin — Wohnen ist ihr Bereich, sie sieht zusätzlich alle Klient*innen.',
},
{
name: 'Simon Binder',
name: 'Simon B.',
role: 'JOBCOACH',
scope: 'OWN_DOMAIN',
isSystemAdmin: false,
Expand Down
43 changes: 42 additions & 1 deletion src/app/(admin)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const GREETING_BY_DAY_PART: Record<DayPart, 'greetingMorning' | 'greetingDay' |
day: 'greetingDay',
evening: 'greetingEvening',
}
import { RESIDENT_NAME_SELECT } from '@/lib/utils/resident-name'
import { buildJobQueue } from '@/lib/jobcoach/queue'
import { RESIDENT_NAME_SELECT, residentName } from '@/lib/utils/resident-name'
import { getCheckInInterval, VERY_OVERDUE_THRESHOLD_DAYS } from '@/lib/config/checkin-intervals'
import {
PROBLEM_DETECTION,
Expand Down Expand Up @@ -91,6 +92,7 @@ export default async function AdminDashboard() {
activeStaffCount,
neverSignedInStaffCount,
assignedResidentCount,
jobCaseload,
] = await Promise.all([
db.$count(resident),
// Only used to pick the first setup step, which requires housing:write —
Expand Down Expand Up @@ -205,12 +207,50 @@ export default async function AdminDashboard() {
viewer.scope === 'ALL_DOMAINS' || !user
? null
: db.$count(careAssignment, eq(careAssignment.staffId, user.id)),

// The job coach's own caseload, with what they'd need to know about it.
//
// Scoped to THEIR seat rather than to every client: a coach's queue is the
// people they hold, and a product-wide list would recreate the aggregate
// that told them nothing. Only fetched for a viewer whose work this is —
// `learning:write` is the Job domain's verb.
show('learning') && user
? db.query.careAssignment.findMany({
where: and(eq(careAssignment.staffId, user.id), eq(careAssignment.role, 'JOB')),
columns: {},
with: {
resident: {
// RESIDENT_NAME_SELECT already carries `id`, `code` and
// `displayName`; naming `id` again would be redundant, not wrong.
columns: { ...RESIDENT_NAME_SELECT, createdAt: true },
with: {
learningRecords: { columns: { kind: true, status: true, updatedAt: true } },
opportunityApplications: { columns: { stage: true } },
},
},
},
})
: [],
])

// =============================================================================
// Calculate Core Stats
// =============================================================================

// The Job domain's own work queue. Derived here so the dashboard receives
// rows rather than raw records — the rule for what counts lives in
// lib/jobcoach/queue.ts, next to the evidence that justifies each signal.
const jobQueue = buildJobQueue(
jobCaseload.map(({ resident }) => ({
residentId: resident.id,
name: residentName(resident),
createdAt: resident.createdAt,
learningRecords: resident.learningRecords,
applications: resident.opportunityApplications,
})),
new Date(),
)

const totalBeds = units.reduce((sum, u) => sum + u.totalBeds, 0)

// =============================================================================
Expand Down Expand Up @@ -376,6 +416,7 @@ export default async function AdminDashboard() {
residentCount={residentCount}
housingUnitCount={housingUnitCount}
assignedResidentCount={assignedResidentCount}
jobQueue={jobQueue}
occupiedBeds={occupiedBeds}
totalBeds={totalBeds}
totalPlacements={totalPlacements}
Expand Down
46 changes: 45 additions & 1 deletion src/components/dashboard/ActionDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type DashboardSection,
} from '@/lib/config/dashboard'
import type { StaffCapabilities, StaffRole } from '@/lib/auth/role-policy'
import { JOB_SIGNAL_IDS, type JobQueueItem } from '@/lib/jobcoach/queue'
import { JOB_SIGNAL_COPY } from '@/lib/config/job-integration-docs'
import { INCIDENT_TYPE_LABELS_SHORT, DASHBOARD_LABELS } from '@/lib/constants/labels'
import { daysSinceCeil } from '@/lib/utils'
import { residentName } from '@/lib/utils/resident-name'
Expand Down Expand Up @@ -58,6 +60,13 @@ interface ActionDashboardProps {
* you", which the global count above cannot see.
*/
assignedResidentCount: number | null
/**
* The Job domain's own work, one row per (client, signal).
*
* Empty for a viewer who does not hold `learning:write` — the page does not
* even run the query for them. @see lib/jobcoach/queue.ts
*/
jobQueue: JobQueueItem[]

// Action items
overdueCheckIns: OverdueCheckIn[]
Expand Down Expand Up @@ -116,6 +125,7 @@ export function ActionDashboard({
residentCount,
housingUnitCount,
assignedResidentCount,
jobQueue,
occupiedBeds,
totalBeds,
totalPlacements,
Expand Down Expand Up @@ -154,12 +164,18 @@ export function ActionDashboard({

// Count total issues — every queue that waits on a staff answer, not just
// the placement ones.
// Every term here used to be a HOUSING queue — check-ins, placements,
// transfers, governance. A Jobcoach holds none of those permissions, so
// their count was structurally zero and the dashboard congratulated them on
// a day with real work in it. Observed in production 2026-09-02 with a
// client assigned the same morning. @see lib/jobcoach/queue.ts
const totalIssues =
criticalIncidents.length +
overdueCheckIns.length +
unplacedResidents.length +
pendingTransfers.length +
proposalsAwaitingStaff.length
proposalsAwaitingStaff.length +
jobQueue.length

// "Nothing to do" and "nothing entered yet" are different facts and get
// different screens. @see lib/config/dashboard.ts
Expand Down Expand Up @@ -342,6 +358,34 @@ export function ActionDashboard({
/>
)}

{/* The Job domain's work, one tile per signal. Named clients, not
a bare count: the screen this replaces reported "keine
dringenden Aufgaben" to a coach whose client was created that
morning, and never mentioned him. */}
{JOB_SIGNAL_IDS.map((signal) => {
const rows = jobQueue.filter((row) => row.signal === signal)
if (rows.length === 0) return null
const copy = JOB_SIGNAL_COPY[signal]
return (
<ActionTile
key={signal}
title={copy.title}
count={rows.length}
description={copy.action}
href={`/residents/${rows[0].residentId}`}
urgency={urgencyForOpenCount(rows.length)}
items={rows.slice(0, DISPLAY_LIMITS.dashboardItems).map((row) => ({
label: row.name,
// The signal is already the tile's title, so the sublabel
// carries the move rather than repeating it.
sublabel: copy.action,
href: `/residents/${row.residentId}`,
}))}
allHref="/learning?board=job"
/>
)
})}

{unplacedResidents.length > 0 && (
<ActionTile
title={DASHBOARD_LABELS.tilePlaceResidents}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const BASE_PROPS = {
// Oversight over every domain: no single seat, so "nobody is assigned to
// you" is not a question that applies. Specialists are exercised below.
assignedResidentCount: null,
jobQueue: [],
housingUnitCount: 4,
occupiedBeds: 10,
totalBeds: 20,
Expand Down
18 changes: 5 additions & 13 deletions src/lib/config/algorithm-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,11 @@
// SCIENTIFIC RESEARCH SOURCES
// =============================================================================

export type EvidenceStrength = 'strong' | 'moderate' | 'preliminary'

export interface ResearchSource {
id: string
title: string
authors?: string
year?: number
publication?: string
url?: string
region: 'CH' | 'DE' | 'INT' // Switzerland, Germany, International
keyFindings: string[]
evidenceStrength: EvidenceStrength
}
// Defined in ./evidence so job integration can cite research without
// importing the housing algorithm's module for a type it merely shares.
// Re-exported here so no existing import path changes.
export type { EvidenceStrength, ResearchSource } from './evidence'
import type { EvidenceStrength, ResearchSource } from './evidence'

export const RESEARCH_SOURCES: ResearchSource[] = [
// ─── Swiss Research (Priority) ───────────────────────────────────────────
Expand Down
44 changes: 44 additions & 0 deletions src/lib/config/evidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* How this product cites evidence — shared by every domain that claims one.
*
* The housing side has documented its scientific basis since the beginning:
* which factor is weighted, why, and what research says so. Job integration
* had none, which is not a small asymmetry — placement and employment are the
* two things AOZ is measured on, and only one of them could explain itself.
*
* These types live here rather than in `algorithm-docs.ts` so the second
* domain does not import the first's module for a type it merely shares.
* `algorithm-docs.ts` re-exports them, so no existing import path changes.
*/

/**
* How much weight a claim carries.
*
* Deliberately three levels, not a number. A false precision — "0.82
* confidence" — invites the reader to compute with it, and none of this is
* meta-analysed to that resolution.
*/
export type EvidenceStrength = 'strong' | 'moderate' | 'preliminary'

/**
* Where a claim comes from.
*
* `region` is not decoration. Swiss and German labour-market findings are the
* ones that transfer to AOZ's setting, because the institutions differ:
* permit regimes, recognition procedures and the structure of vocational
* training are not portable, and an American supported-employment trial can be
* strong evidence for a mechanism while saying nothing about a Swiss process.
* Sorting CH/DE ahead of INT is how the reader sees that at a glance.
*/
export interface ResearchSource {
id: string
title: string
authors?: string
year?: number
publication?: string
url?: string
/** Switzerland, Germany/Austria, International. */
region: 'CH' | 'DE' | 'INT'
keyFindings: string[]
evidenceStrength: EvidenceStrength
}
Loading
Loading