Compare commits
36 Commits
72c75fd1a9
...
0d42db7111
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d42db7111 | |||
| 088b783731 | |||
| 071b1b78ae | |||
| 97d353930c | |||
| dbdd51243d | |||
| a8c7d5b41d | |||
| 120d8a7a7b | |||
| 4c92a3a559 | |||
| 24e0f8963f | |||
| 6956ad001b | |||
| 75c03029bf | |||
| 2f8db26cc4 | |||
| a5deb0ea8b | |||
| bbe17fc66a | |||
| 9ec71ae0ed | |||
| 9d61d2c8ca | |||
| fbfd25ffff | |||
| f38e67252b | |||
| 0c87d9f5a4 | |||
| 8830c223aa | |||
| 52ee98d8aa | |||
| 03b4c6cafb | |||
| 9ed77f99a8 | |||
| afc3876210 | |||
| c37fdab8fa | |||
| 980297ea92 | |||
| 8bdb162a07 | |||
| 2886685573 | |||
| b18746ecee | |||
| 6c26518806 | |||
| f4a6b5e32c | |||
| 92502beb03 | |||
| a596b5ac82 | |||
| cf5399a767 | |||
| f7e9c88762 | |||
| ee73efce11 |
+587
-587
File diff suppressed because it is too large
Load Diff
@@ -8,3 +8,16 @@ Stories: 32 (US-001 through US-032)
|
||||
## Status
|
||||
|
||||
No iterations completed yet.
|
||||
2026-02-13 22:57 | PASS | US-001: Clean up unused legacy components and hooks | model=opus elapsed=01:58 tools=18
|
||||
2026-02-13 22:59 | PASS | US-002: Add new TypeScript types and CSS custom properties for depth features | model=sonnet elapsed=01:54 tools=11
|
||||
2026-02-13 23:03 | PASS | US-003: Create DetailPanelContext, DetailPanel component, and useFocusTrap hook | model=sonnet elapsed=03:39 tools=22
|
||||
2026-02-13 23:06 | PASS | US-004: Create SubNav component and useActiveSection hook | model=sonnet elapsed=02:54 tools=18
|
||||
2026-02-13 23:08 | PASS | US-005: Expand skills data from 5 to ~20 with three categories | model=sonnet elapsed=01:58 tools=11
|
||||
2026-02-13 23:10 | PASS | US-006: Add KPI story data and update 4th KPI | model=sonnet elapsed=01:59 tools=9
|
||||
2026-02-13 23:11 | PASS | US-007: Create education extras data file | model=sonnet elapsed=01:25 tools=10
|
||||
2026-02-13 23:15 | PASS | US-008: Restructure DashboardLayout with SubNav, new tile order, and DetailPanel | model=sonnet elapsed=03:10 tools=27
|
||||
2026-02-13 23:17 | PASS | US-009: Create constellation data mapping file | model=sonnet elapsed=02:20 tools=10
|
||||
2026-02-13 23:50 | PASS | US-011: Modify CoreSkillsTile: full width, categorised groups, panel triggers | model=opus elapsed=02:54 tools=22
|
||||
2026-02-13 23:52 | PASS | US-012: Modify ProjectsTile: half width, compact card grid, panel trigger | model=sonnet elapsed=02:16 tools=11
|
||||
2026-02-13 23:55 | PASS | US-013: Modify LastConsultationTile: add panel trigger | model=sonnet elapsed=02:20 tools=15
|
||||
2026-02-13 23:58 | PASS | US-014: Modify CareerActivityTile: panel triggers and hover preview | model=sonnet elapsed=02:49 tools=14
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ralph Wiggum Loop - PRD-driven variant.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||
invocation for each story. Memory persists via filesystem only: git commits,
|
||||
prd.json (passes field), and progress.txt.
|
||||
|
||||
Each iteration works on ONE user story (in priority order).
|
||||
When all stories pass, the loop completes.
|
||||
|
||||
Circuit breakers prevent runaway costs:
|
||||
- No git changes for N consecutive iterations (stalled)
|
||||
- Same error repeated N consecutive iterations (stuck)
|
||||
|
||||
.PARAMETER Model
|
||||
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||
|
||||
.PARAMETER MaxNoProgress
|
||||
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
|
||||
|
||||
.PARAMETER MaxSameError
|
||||
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
|
||||
|
||||
.PARAMETER StartFrom
|
||||
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||
|
||||
.EXAMPLE
|
||||
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||
|
||||
.EXAMPLE
|
||||
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$Model = "opus",
|
||||
[int]$MaxNoProgress = 3,
|
||||
[int]$MaxSameError = 3,
|
||||
[string]$StartFrom = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$prdFile = Join-Path $scriptDir "prd.json"
|
||||
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||
$logDir = Join-Path $scriptDir "logs"
|
||||
|
||||
# --- Find project root (git repo root) ---
|
||||
|
||||
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $projectRoot) {
|
||||
Write-Error "Not inside a git repository. Run from the project directory."
|
||||
exit 1
|
||||
}
|
||||
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
if (-not (Test-Path $prdFile)) {
|
||||
Write-Error "prd.json not found at $prdFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure logs directory exists
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||
Write-Host "Created logs directory"
|
||||
}
|
||||
|
||||
# --- PRD Read/Write ---
|
||||
|
||||
function Read-Prd {
|
||||
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Save-Prd {
|
||||
param($prdObj)
|
||||
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||
}
|
||||
|
||||
$prd = Read-Prd
|
||||
|
||||
# --- Git Setup ---
|
||||
|
||||
$BranchName = $prd.branchName
|
||||
|
||||
if ($BranchName) {
|
||||
$currentBranch = git branch --show-current
|
||||
if ($currentBranch -ne $BranchName) {
|
||||
$branchExists = git branch --list $BranchName
|
||||
if ($branchExists) {
|
||||
Write-Host "Switching to existing branch: $BranchName"
|
||||
git checkout $BranchName
|
||||
} else {
|
||||
Write-Host "Creating branch: $BranchName"
|
||||
git checkout -b $BranchName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||
|
||||
if ($StartFrom) {
|
||||
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||
$skippedCount = 0
|
||||
foreach ($story in $prd.userStories) {
|
||||
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||
$story.passes = $true
|
||||
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||
$skippedCount++
|
||||
}
|
||||
}
|
||||
if ($skippedCount -gt 0) {
|
||||
Save-Prd $prd
|
||||
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
# --- Circuit Breaker State ---
|
||||
|
||||
$noProgressCount = 0
|
||||
$lastErrorSignature = ""
|
||||
$sameErrorCount = 0
|
||||
|
||||
# --- Prompt Generation ---
|
||||
|
||||
function Build-StoryPrompt {
|
||||
param(
|
||||
$story,
|
||||
$prdObj,
|
||||
[array]$completedStories
|
||||
)
|
||||
|
||||
# Build completed list
|
||||
$completedSection = ""
|
||||
if ($completedStories.Count -gt 0) {
|
||||
$completedLines = ($completedStories | ForEach-Object {
|
||||
"- $($_.id): $($_.title)"
|
||||
}) -join "`n"
|
||||
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||
}
|
||||
|
||||
# Build criteria list
|
||||
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||
|
||||
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||
$sid = $story.id
|
||||
$stitle = $story.title
|
||||
$sdesc = $story.description
|
||||
$pdesc = $prdObj.description
|
||||
|
||||
$prompt = @(
|
||||
"# Ralph Iteration: $sid - $stitle"
|
||||
""
|
||||
"## Project"
|
||||
"$pdesc"
|
||||
""
|
||||
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||
""
|
||||
"## Your Task"
|
||||
""
|
||||
"**${sid}: $stitle**"
|
||||
""
|
||||
"$sdesc"
|
||||
""
|
||||
"## Acceptance Criteria"
|
||||
""
|
||||
"$criteriaLines"
|
||||
""
|
||||
"## Reference Documents"
|
||||
""
|
||||
"Read these as needed for implementation detail:"
|
||||
""
|
||||
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||
"$completedSection"
|
||||
"## Workflow"
|
||||
""
|
||||
"1. Read CLAUDE.md to understand project conventions"
|
||||
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||
"3. Read existing source files you will modify to understand current patterns"
|
||||
"4. Implement ALL acceptance criteria"
|
||||
"5. Run npm run typecheck - fix any type errors"
|
||||
"6. Run npm run build - fix any build errors"
|
||||
"7. Stage and commit your changes:"
|
||||
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||
""
|
||||
"## Rules"
|
||||
""
|
||||
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||
"- Read files before modifying them."
|
||||
"- Follow existing patterns and conventions in the codebase."
|
||||
"- Use lucide-react for icons, never unicode symbols."
|
||||
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||
"- Commit specific files, not git add -A."
|
||||
"- Do NOT start a dev server (npm run dev). One is already running on port $devServerPort. Do NOT run any background tasks."
|
||||
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||
) -join "`n"
|
||||
|
||||
return $prompt
|
||||
}
|
||||
|
||||
# --- Banner ---
|
||||
|
||||
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||
$totalCount = $prd.userStories.Count
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Dev server port (assumed to be running externally)
|
||||
$devServerPort = 5173
|
||||
Write-Host "Dev server assumed running on port $devServerPort" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
# --- Story Loop ---
|
||||
|
||||
$iterationCount = 0
|
||||
$originalDir = Get-Location
|
||||
Set-Location $projectRoot
|
||||
|
||||
try {
|
||||
|
||||
while ($true) {
|
||||
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||
$prd = Read-Prd
|
||||
|
||||
# Partition stories
|
||||
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||
|
||||
# Check if all done
|
||||
if ($pendingStories.Count -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||
break
|
||||
}
|
||||
|
||||
$currentStory = $pendingStories[0]
|
||||
$iterationCount++
|
||||
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||
|
||||
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||
$pctStr = "${pctComplete}%"
|
||||
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||
|
||||
# Record HEAD before this iteration
|
||||
$headBefore = git rev-parse HEAD 2>$null
|
||||
|
||||
$iterStart = Get-Date
|
||||
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
# Generate prompt for this story
|
||||
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||
|
||||
# --- Spawn Claude ---
|
||||
|
||||
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
|
||||
$maxRetries = 10
|
||||
$retryCount = 0
|
||||
$outputString = ""
|
||||
$apiOverloaded = $false
|
||||
|
||||
do {
|
||||
$apiOverloaded = $false
|
||||
$textBuilder = [System.Text.StringBuilder]::new()
|
||||
$toolCount = 0
|
||||
|
||||
# Clear raw log file for this attempt
|
||||
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
|
||||
|
||||
if ($retryCount -gt 0) {
|
||||
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
|
||||
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
|
||||
Start-Sleep -Seconds $backoffSeconds
|
||||
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# --- Spawn Claude via Process.Start for clean shutdown control ---
|
||||
# Using Process.Start instead of pipeline so we can break on the result
|
||||
# event and force-kill the process tree. The pipeline approach hangs when
|
||||
# Claude spawns background tasks (e.g. npm run dev) that keep stdout open.
|
||||
|
||||
$promptTempFile = Join-Path $logDir "$($currentStory.id).prompt.tmp"
|
||||
$promptContent | Set-Content -Path $promptTempFile -Encoding UTF8
|
||||
|
||||
$claudeArgs = "--print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json"
|
||||
$psi = [System.Diagnostics.ProcessStartInfo]::new()
|
||||
$psi.FileName = "cmd.exe"
|
||||
$psi.Arguments = "/c type `"$promptTempFile`" | claude $claudeArgs"
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WorkingDirectory = $projectRoot
|
||||
|
||||
$claudeProc = [System.Diagnostics.Process]::Start($psi)
|
||||
|
||||
# Drain stderr async to prevent buffer deadlock
|
||||
$claudeProc.add_ErrorDataReceived({ param($s,$e) })
|
||||
$claudeProc.BeginErrorReadLine()
|
||||
|
||||
try {
|
||||
while ($null -ne ($line = $claudeProc.StandardOutput.ReadLine())) {
|
||||
$line = $line.Trim()
|
||||
if (-not $line) { continue }
|
||||
|
||||
# Save raw event for debugging
|
||||
try {
|
||||
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
|
||||
$isResultEvent = $false
|
||||
try {
|
||||
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||
|
||||
# --- Tool use start ---
|
||||
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
|
||||
$toolCount++
|
||||
$toolName = $evt.content_block.name
|
||||
Write-Host " [$toolName]" -ForegroundColor DarkCyan
|
||||
}
|
||||
# --- Streaming text ---
|
||||
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
|
||||
Write-Host -NoNewline $evt.delta.text
|
||||
[void]$textBuilder.Append($evt.delta.text)
|
||||
}
|
||||
# --- Result event (terminal — stop reading after this) ---
|
||||
elseif ($evt.type -eq 'result') {
|
||||
if ($evt.subtype -eq 'error_result' -and $evt.error) {
|
||||
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
|
||||
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
|
||||
}
|
||||
elseif ($evt.result) {
|
||||
[void]$textBuilder.AppendLine($evt.result)
|
||||
}
|
||||
$isResultEvent = $true
|
||||
}
|
||||
# --- Message-level content ---
|
||||
elseif ($evt.message -and $evt.message.content) {
|
||||
foreach ($block in $evt.message.content) {
|
||||
if ($block.type -eq 'text' -and $block.text) {
|
||||
Write-Host $block.text
|
||||
[void]$textBuilder.AppendLine($block.text)
|
||||
}
|
||||
elseif ($block.type -eq 'tool_use') {
|
||||
$toolCount++
|
||||
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ($line -and $line -notmatch '^\s*["\{\[\}\]]') {
|
||||
Write-Host $line -ForegroundColor DarkYellow
|
||||
[void]$textBuilder.AppendLine($line)
|
||||
}
|
||||
}
|
||||
|
||||
# Result is always the final stream event — stop reading
|
||||
if ($isResultEvent) { break }
|
||||
}
|
||||
} finally {
|
||||
# Kill the Claude process tree to prevent orphaned cmd.exe/node processes
|
||||
if ($claudeProc -and -not $claudeProc.HasExited) {
|
||||
try {
|
||||
taskkill /T /F /PID $claudeProc.Id 2>$null | Out-Null
|
||||
} catch { }
|
||||
}
|
||||
Remove-Item -Path $promptTempFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$outputString = $textBuilder.ToString()
|
||||
|
||||
# Check for 529 overloaded error
|
||||
if ($outputString -match "529.*overloaded|overloaded_error") {
|
||||
$apiOverloaded = $true
|
||||
$retryCount++
|
||||
if ($retryCount -ge $maxRetries) {
|
||||
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
# Check for usage limit with cooldown
|
||||
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
|
||||
$resetHour = [int]$Matches[1]
|
||||
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
|
||||
$resetAmPm = $Matches[3]
|
||||
|
||||
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
|
||||
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
|
||||
|
||||
$now = Get-Date
|
||||
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
|
||||
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
|
||||
$resetTime = $resetTime.AddMinutes(2)
|
||||
|
||||
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
|
||||
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds $waitSeconds
|
||||
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
|
||||
|
||||
$apiOverloaded = $true
|
||||
}
|
||||
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||
|
||||
# Save log
|
||||
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||
|
||||
# Show elapsed time
|
||||
$elapsed = (Get-Date) - $iterStart
|
||||
Write-Host ""
|
||||
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||
|
||||
# --- Detect signals ---
|
||||
|
||||
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||
$headAfter = git rev-parse HEAD 2>$null
|
||||
$hasGitChanges = $headAfter -ne $headBefore
|
||||
|
||||
# --- Update story status ---
|
||||
|
||||
if ($storyComplete) {
|
||||
# Mark story as passed in prd.json
|
||||
$prd = Read-Prd
|
||||
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||
if ($storyToUpdate) {
|
||||
$alreadyDone = if (-not $hasGitChanges) { " (already committed)" } else { "" }
|
||||
$storyToUpdate.passes = $true
|
||||
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model.$alreadyDone"
|
||||
}
|
||||
Save-Prd $prd
|
||||
|
||||
# Append to progress.txt
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$el = $elapsed.ToString('mm\:ss')
|
||||
$tag = if ($hasGitChanges) { "PASS" } else { "PASS (no new commits)" }
|
||||
$progressEntry = "$ts | $tag | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
|
||||
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||
if (-not $hasGitChanges) {
|
||||
Write-Host " (Work was already committed)" -ForegroundColor DarkGray
|
||||
}
|
||||
$noProgressCount = 0
|
||||
$sameErrorCount = 0
|
||||
$lastErrorSignature = ""
|
||||
}
|
||||
elseif ($storyBlocked) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||
# Blocked counts as no progress
|
||||
$noProgressCount++
|
||||
}
|
||||
else {
|
||||
# No completion signal
|
||||
if ($hasGitChanges) {
|
||||
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
$noProgressCount = 0
|
||||
} else {
|
||||
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||
$noProgressCount++
|
||||
}
|
||||
}
|
||||
|
||||
# --- Circuit Breaker: No Progress ---
|
||||
|
||||
if ($noProgressCount -ge $MaxNoProgress) {
|
||||
Write-Host ""
|
||||
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||
Write-Host "Stuck on: $($currentStory.id) - $($currentStory.title)" -ForegroundColor Red
|
||||
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||
break
|
||||
}
|
||||
|
||||
# --- Circuit Breaker: Repeated Error ---
|
||||
|
||||
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
|
||||
if ($errorLines) {
|
||||
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
|
||||
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
|
||||
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
|
||||
$sameErrorCount++
|
||||
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
|
||||
if ($sameErrorCount -ge $MaxSameError) {
|
||||
Write-Host ""
|
||||
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
|
||||
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||
break
|
||||
}
|
||||
} elseif ($currentErrorSignature) {
|
||||
$sameErrorCount = 0
|
||||
}
|
||||
$lastErrorSignature = $currentErrorSignature
|
||||
} else {
|
||||
$sameErrorCount = 0
|
||||
$lastErrorSignature = ""
|
||||
}
|
||||
|
||||
# --- Dynamic Model Selection ---
|
||||
|
||||
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||
$nextModel = $Matches[1]
|
||||
if ($nextModel -ne $Model) {
|
||||
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||
$Model = $nextModel
|
||||
}
|
||||
}
|
||||
|
||||
# Brief pause between iterations
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
} finally {
|
||||
Set-Location $originalDir
|
||||
}
|
||||
|
||||
# --- Final Summary ---
|
||||
|
||||
$prd = Read-Prd
|
||||
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||
$finalTotal = $prd.userStories.Count
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
|
||||
if ($finalPassed -eq $finalTotal) {
|
||||
exit 0
|
||||
} else {
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ralph Wiggum Loop — PRD-driven variant.
|
||||
|
||||
.DESCRIPTION
|
||||
Iterates through user stories in prd.json, spawning a fresh `claude --print`
|
||||
invocation for each story. Memory persists via filesystem only: git commits,
|
||||
prd.json (passes field), and progress.txt.
|
||||
|
||||
Each iteration works on ONE user story (in priority order).
|
||||
When all stories pass, the loop completes.
|
||||
|
||||
Circuit breakers prevent runaway costs:
|
||||
- No git changes for N consecutive iterations (stalled)
|
||||
- Same error repeated N consecutive iterations (stuck)
|
||||
|
||||
.PARAMETER Model
|
||||
Initial Claude model to use. Default: "opus". The agent can dynamically switch
|
||||
models between iterations via <next-model>opus|sonnet</next-model> signals.
|
||||
|
||||
.PARAMETER MaxNoProgress
|
||||
Number of consecutive iterations with no git changes before circuit breaker trips. Default: 3.
|
||||
|
||||
.PARAMETER MaxSameError
|
||||
Number of consecutive iterations with the same error before circuit breaker trips. Default: 3.
|
||||
|
||||
.PARAMETER StartFrom
|
||||
Story ID to start from (e.g., "US-005"). Treats all earlier stories as already passed.
|
||||
|
||||
.PARAMETER SkipVerify
|
||||
Skip post-iteration typecheck verification. Faster but less safe.
|
||||
|
||||
.EXAMPLE
|
||||
.\.claude\skills\ralph\ralph.ps1 -Model "opus"
|
||||
|
||||
.EXAMPLE
|
||||
.\.claude\skills\ralph\ralph.ps1 -StartFrom "US-010" -Model "sonnet"
|
||||
#>
|
||||
|
||||
param(
|
||||
[string]$Model = "opus",
|
||||
[int]$MaxNoProgress = 3,
|
||||
[int]$MaxSameError = 3,
|
||||
[string]$StartFrom = "",
|
||||
[switch]$SkipVerify
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$prdFile = Join-Path $scriptDir "prd.json"
|
||||
$progressFile = Join-Path $scriptDir "progress.txt"
|
||||
$logDir = Join-Path $scriptDir "logs"
|
||||
|
||||
# --- Find project root (git repo root) ---
|
||||
|
||||
$projectRoot = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $projectRoot) {
|
||||
Write-Error "Not inside a git repository. Run from the project directory."
|
||||
exit 1
|
||||
}
|
||||
$projectRoot = (Resolve-Path $projectRoot).Path
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
if (-not (Test-Path $prdFile)) {
|
||||
Write-Error "prd.json not found at $prdFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure logs directory exists
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir | Out-Null
|
||||
Write-Host "Created logs directory"
|
||||
}
|
||||
|
||||
# --- PRD Read/Write ---
|
||||
|
||||
function Read-Prd {
|
||||
Get-Content -Path $prdFile -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Save-Prd {
|
||||
param($prdObj)
|
||||
$prdObj | ConvertTo-Json -Depth 10 | Set-Content -Path $prdFile -Encoding UTF8
|
||||
}
|
||||
|
||||
$prd = Read-Prd
|
||||
|
||||
# --- Git Setup ---
|
||||
|
||||
$BranchName = $prd.branchName
|
||||
|
||||
if ($BranchName) {
|
||||
$currentBranch = git branch --show-current
|
||||
if ($currentBranch -ne $BranchName) {
|
||||
$branchExists = git branch --list $BranchName
|
||||
if ($branchExists) {
|
||||
Write-Host "Switching to existing branch: $BranchName"
|
||||
git checkout $BranchName
|
||||
} else {
|
||||
Write-Host "Creating branch: $BranchName"
|
||||
git checkout -b $BranchName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Handle StartFrom: mark earlier stories as passed ---
|
||||
|
||||
if ($StartFrom) {
|
||||
$startPriority = [int]($StartFrom -replace 'US-0*', '')
|
||||
$skippedCount = 0
|
||||
foreach ($story in $prd.userStories) {
|
||||
$storyPriority = [int]($story.id -replace 'US-0*', '')
|
||||
if ($storyPriority -lt $startPriority -and $story.passes -ne $true) {
|
||||
$story.passes = $true
|
||||
$story.notes = "Skipped (--StartFrom $StartFrom)"
|
||||
$skippedCount++
|
||||
}
|
||||
}
|
||||
if ($skippedCount -gt 0) {
|
||||
Save-Prd $prd
|
||||
Write-Host "Marked $skippedCount stories before $StartFrom as skipped." -ForegroundColor DarkYellow
|
||||
}
|
||||
}
|
||||
|
||||
# --- Circuit Breaker State ---
|
||||
|
||||
$noProgressCount = 0
|
||||
$lastErrorSignature = ""
|
||||
$sameErrorCount = 0
|
||||
|
||||
# --- Prompt Generation ---
|
||||
|
||||
function Build-StoryPrompt {
|
||||
param(
|
||||
$story,
|
||||
$prdObj,
|
||||
[array]$completedStories
|
||||
)
|
||||
|
||||
# Build completed list
|
||||
$completedSection = ""
|
||||
if ($completedStories.Count -gt 0) {
|
||||
$completedLines = ($completedStories | ForEach-Object {
|
||||
"- $($_.id): $($_.title)"
|
||||
}) -join "`n"
|
||||
$completedSection = "`n## Previously Completed Stories (do not redo)`n$completedLines`n"
|
||||
}
|
||||
|
||||
# Build criteria list
|
||||
$criteriaLines = ($story.acceptanceCriteria | ForEach-Object { "- [ ] $_" }) -join "`n"
|
||||
|
||||
# Build prompt using array-join (avoids PS 5.1 here-string indentation issues)
|
||||
$sid = $story.id
|
||||
$stitle = $story.title
|
||||
$sdesc = $story.description
|
||||
$pdesc = $prdObj.description
|
||||
|
||||
$prompt = @(
|
||||
"# Ralph Iteration: $sid - $stitle"
|
||||
""
|
||||
"## Project"
|
||||
"$pdesc"
|
||||
""
|
||||
"Read CLAUDE.md for full project conventions, architecture, and design system. This is mandatory before starting work."
|
||||
""
|
||||
"## Your Task"
|
||||
""
|
||||
"**${sid}: $stitle**"
|
||||
""
|
||||
"$sdesc"
|
||||
""
|
||||
"## Acceptance Criteria"
|
||||
""
|
||||
"$criteriaLines"
|
||||
""
|
||||
"## Reference Documents"
|
||||
""
|
||||
"Read these as needed for implementation detail:"
|
||||
""
|
||||
"- **CLAUDE.md** - Project conventions, architecture, design tokens, guardrails (READ FIRST)"
|
||||
"- **Ralph/depth-design.md** - Component architecture, props interfaces, CSS specs, data models"
|
||||
"- **Ralph/depth-requirements.md** - Full requirements with content sources and UX patterns"
|
||||
"- **References/CV_v4.md** - Source of truth for all CV content (roles, dates, achievements, numbers)"
|
||||
"$completedSection"
|
||||
"## Workflow"
|
||||
""
|
||||
"1. Read CLAUDE.md to understand project conventions"
|
||||
"2. Read Ralph/depth-design.md sections relevant to this story"
|
||||
"3. Read existing source files you will modify to understand current patterns"
|
||||
"4. Implement ALL acceptance criteria"
|
||||
"5. Run npm run typecheck - fix any type errors"
|
||||
"6. Run npm run build - fix any build errors"
|
||||
"7. Stage and commit your changes:"
|
||||
" git add [specific files] && git commit -m `"${sid}: [descriptive message]`""
|
||||
"8. When ALL criteria are met, output: <story-complete>$sid</story-complete>"
|
||||
""
|
||||
"## Rules"
|
||||
""
|
||||
"- Work ONLY on $sid. Do not modify code for other stories."
|
||||
"- Read files before modifying them."
|
||||
"- Follow existing patterns and conventions in the codebase."
|
||||
"- Use lucide-react for icons, never unicode symbols."
|
||||
"- Use the project's CSS custom properties and Tailwind tokens."
|
||||
"- Commit specific files, not git add -A."
|
||||
"- If genuinely blocked, output <story-blocked>$sid</story-blocked> with explanation."
|
||||
"- To recommend a different model for the NEXT iteration, output <next-model>opus</next-model> or <next-model>sonnet</next-model>."
|
||||
) -join "`n"
|
||||
|
||||
return $prompt
|
||||
}
|
||||
|
||||
# --- Banner ---
|
||||
|
||||
$completedCount = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||
$totalCount = $prd.userStories.Count
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== Ralph Wiggum Loop (PRD-driven) =====" -ForegroundColor Cyan
|
||||
Write-Host "Project: $($prd.project)" -ForegroundColor Cyan
|
||||
Write-Host "Branch: $BranchName | Model: $Model (dynamic switching enabled)" -ForegroundColor Cyan
|
||||
Write-Host "Stories: $completedCount/$totalCount complete" -ForegroundColor Cyan
|
||||
Write-Host "Circuit breakers: no-progress=$MaxNoProgress, same-error=$MaxSameError" -ForegroundColor Cyan
|
||||
if (-not $SkipVerify) { Write-Host "Post-iteration typecheck verification: ON" -ForegroundColor Cyan }
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# --- Dev Server ---
|
||||
|
||||
$devServerPort = 5173
|
||||
$devServerPid = $null
|
||||
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||
Write-Host "Dev server detected on port $devServerPort" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "Starting dev server (port $devServerPort)..." -ForegroundColor Cyan
|
||||
$devProc = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $projectRoot -PassThru -WindowStyle Minimized
|
||||
$devServerPid = $devProc.Id
|
||||
|
||||
for ($w = 1; $w -le 20; $w++) {
|
||||
Start-Sleep -Seconds 1
|
||||
try {
|
||||
$null = Invoke-WebRequest -Uri "http://localhost:$devServerPort" -TimeoutSec 2 -ErrorAction Stop
|
||||
Write-Host "Dev server ready on port $devServerPort" -ForegroundColor Green
|
||||
break
|
||||
} catch {
|
||||
if ($w -eq 20) {
|
||||
Write-Warning "Dev server may not be ready — visual review steps may fail"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# --- Story Loop ---
|
||||
|
||||
$iterationCount = 0
|
||||
$originalDir = Get-Location
|
||||
Set-Location $projectRoot
|
||||
|
||||
try {
|
||||
|
||||
while ($true) {
|
||||
# Re-read PRD each iteration (in case previous iteration updated it)
|
||||
$prd = Read-Prd
|
||||
|
||||
# Partition stories
|
||||
$completedStories = @($prd.userStories | Where-Object { $_.passes -eq $true })
|
||||
$pendingStories = @($prd.userStories | Where-Object { $_.passes -ne $true } | Sort-Object { $_.priority })
|
||||
|
||||
# Check if all done
|
||||
if ($pendingStories.Count -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "===== ALL STORIES COMPLETE =====" -ForegroundColor Green
|
||||
Write-Host "$($completedStories.Count)/$($prd.userStories.Count) stories passed." -ForegroundColor Green
|
||||
Write-Host "Branch: $BranchName" -ForegroundColor Green
|
||||
break
|
||||
}
|
||||
|
||||
$currentStory = $pendingStories[0]
|
||||
$iterationCount++
|
||||
$pctComplete = [math]::Round(($completedStories.Count / $prd.userStories.Count) * 100)
|
||||
|
||||
$storyLabel = "$($currentStory.id): $($currentStory.title)"
|
||||
$pctStr = "${pctComplete}%"
|
||||
$progressMsg = " Progress: $($completedStories.Count)/$($prd.userStories.Count) ($pctStr) - Remaining: $($pendingStories.Count)"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "--- Iteration $iterationCount - $storyLabel ---" -ForegroundColor Yellow
|
||||
Write-Host $progressMsg -ForegroundColor DarkGray
|
||||
|
||||
# Record HEAD before this iteration
|
||||
$headBefore = git rev-parse HEAD 2>$null
|
||||
|
||||
$iterStart = Get-Date
|
||||
Write-Host " Started: $($iterStart.ToString('HH:mm:ss')) | Model: $Model" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
# Generate prompt for this story
|
||||
$promptContent = Build-StoryPrompt -story $currentStory -prdObj $prd -completedStories $completedStories
|
||||
|
||||
# --- Spawn Claude ---
|
||||
|
||||
$logFile = Join-Path $logDir "$($currentStory.id).log"
|
||||
$rawLogFile = Join-Path $logDir "$($currentStory.id).raw.jsonl"
|
||||
$maxRetries = 10
|
||||
$retryCount = 0
|
||||
$outputString = ""
|
||||
$apiOverloaded = $false
|
||||
|
||||
do {
|
||||
$apiOverloaded = $false
|
||||
$textBuilder = [System.Text.StringBuilder]::new()
|
||||
$toolCount = 0
|
||||
|
||||
# Clear raw log file for this attempt
|
||||
if (Test-Path $rawLogFile) { Remove-Item $rawLogFile -Force }
|
||||
|
||||
if ($retryCount -gt 0) {
|
||||
$backoffSeconds = [Math]::Pow(2, $retryCount - 1)
|
||||
Write-Host " [Retry $retryCount/$maxRetries] API overloaded, waiting $backoffSeconds seconds..." -ForegroundColor DarkYellow
|
||||
Start-Sleep -Seconds $backoffSeconds
|
||||
Write-Host " Retrying Claude invocation..." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
$promptContent | claude --print --verbose --dangerously-skip-permissions --model $Model --output-format stream-json 2>&1 | ForEach-Object {
|
||||
$line = $_.ToString().Trim()
|
||||
if (-not $line) { return }
|
||||
|
||||
# Save raw event for debugging
|
||||
try {
|
||||
Add-Content -Path $rawLogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
|
||||
try {
|
||||
$evt = $line | ConvertFrom-Json -ErrorAction Stop
|
||||
|
||||
# --- Tool use start ---
|
||||
if ($evt.type -eq 'content_block_start' -and $evt.content_block.type -eq 'tool_use') {
|
||||
$toolCount++
|
||||
$toolName = $evt.content_block.name
|
||||
Write-Host " [$toolName]" -ForegroundColor DarkCyan
|
||||
}
|
||||
# --- Streaming text ---
|
||||
elseif ($evt.type -eq 'content_block_delta' -and $evt.delta.type -eq 'text_delta' -and $evt.delta.text) {
|
||||
Write-Host -NoNewline $evt.delta.text
|
||||
[void]$textBuilder.Append($evt.delta.text)
|
||||
}
|
||||
# --- Result event ---
|
||||
elseif ($evt.type -eq 'result') {
|
||||
if ($evt.subtype -eq 'error_result' -and $evt.error) {
|
||||
Write-Host " [ERROR] $($evt.error)" -ForegroundColor Red
|
||||
[void]$textBuilder.AppendLine("ERROR: $($evt.error)")
|
||||
}
|
||||
elseif ($evt.result) {
|
||||
[void]$textBuilder.AppendLine($evt.result)
|
||||
}
|
||||
}
|
||||
# --- Message-level content ---
|
||||
elseif ($evt.message -and $evt.message.content) {
|
||||
foreach ($block in $evt.message.content) {
|
||||
if ($block.type -eq 'text' -and $block.text) {
|
||||
Write-Host $block.text
|
||||
[void]$textBuilder.AppendLine($block.text)
|
||||
}
|
||||
elseif ($block.type -eq 'tool_use') {
|
||||
$toolCount++
|
||||
Write-Host " [$($block.name)]" -ForegroundColor DarkCyan
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ($line -and $line -notmatch '^\s*[\{\[\}\]"]') {
|
||||
Write-Host $line -ForegroundColor DarkYellow
|
||||
[void]$textBuilder.AppendLine($line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$outputString = $textBuilder.ToString()
|
||||
|
||||
# Check for 529 overloaded error
|
||||
if ($outputString -match "529.*overloaded|overloaded_error") {
|
||||
$apiOverloaded = $true
|
||||
$retryCount++
|
||||
if ($retryCount -ge $maxRetries) {
|
||||
Write-Host " [ERROR] API overloaded after $maxRetries retries, giving up." -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
# Check for usage limit with cooldown
|
||||
elseif ($outputString -match "(?i)usage limit reached.*reset at (\d{1,2})(?::(\d{2}))?\s*(am|pm)") {
|
||||
$resetHour = [int]$Matches[1]
|
||||
$resetMinute = if ($Matches[2]) { [int]$Matches[2] } else { 0 }
|
||||
$resetAmPm = $Matches[3]
|
||||
|
||||
if ($resetAmPm -ieq "pm" -and $resetHour -ne 12) { $resetHour += 12 }
|
||||
elseif ($resetAmPm -ieq "am" -and $resetHour -eq 12) { $resetHour = 0 }
|
||||
|
||||
$now = Get-Date
|
||||
$resetTime = Get-Date -Hour $resetHour -Minute $resetMinute -Second 0
|
||||
if ($resetTime -le $now) { $resetTime = $resetTime.AddDays(1) }
|
||||
$resetTime = $resetTime.AddMinutes(2)
|
||||
|
||||
$waitSeconds = [Math]::Ceiling(($resetTime - $now).TotalSeconds)
|
||||
$waitMinutes = [Math]::Ceiling($waitSeconds / 60)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " [USAGE LIMIT] Reset at $($Matches[1]) $resetAmPm. Cooling down ~$waitMinutes minutes (until $($resetTime.ToString('HH:mm')))..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds $waitSeconds
|
||||
Write-Host " [USAGE LIMIT] Cooldown complete. Retrying..." -ForegroundColor Green
|
||||
|
||||
$apiOverloaded = $true
|
||||
}
|
||||
} while ($apiOverloaded -and $retryCount -lt $maxRetries)
|
||||
|
||||
# Save log
|
||||
$outputString | Set-Content -Path $logFile -Encoding UTF8
|
||||
|
||||
# Show elapsed time
|
||||
$elapsed = (Get-Date) - $iterStart
|
||||
Write-Host ""
|
||||
Write-Host " Finished: $(Get-Date -Format 'HH:mm:ss') (elapsed: $($elapsed.ToString('mm\:ss')), tools: $toolCount)" -ForegroundColor DarkGray
|
||||
|
||||
# --- Detect signals ---
|
||||
|
||||
$storyComplete = $outputString -match "<story-complete>$([regex]::Escape($currentStory.id))</story-complete>"
|
||||
$storyBlocked = $outputString -match "<story-blocked>$([regex]::Escape($currentStory.id))</story-blocked>"
|
||||
$headAfter = git rev-parse HEAD 2>$null
|
||||
$hasGitChanges = $headAfter -ne $headBefore
|
||||
|
||||
# --- Post-iteration typecheck verification ---
|
||||
|
||||
$typecheckPassed = $true
|
||||
if ($storyComplete -and $hasGitChanges -and -not $SkipVerify) {
|
||||
Write-Host " Verifying typecheck..." -ForegroundColor DarkGray
|
||||
$typecheckOutput = npm run typecheck 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " [VERIFY FAIL] Typecheck failed after completion signal. Not marking as passed." -ForegroundColor Red
|
||||
$typecheckPassed = $false
|
||||
} else {
|
||||
Write-Host " [VERIFY OK] Typecheck passed." -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
# --- Update story status ---
|
||||
|
||||
if ($storyComplete -and $hasGitChanges -and $typecheckPassed) {
|
||||
# Mark story as passed in prd.json
|
||||
$prd = Read-Prd
|
||||
$storyToUpdate = $prd.userStories | Where-Object { $_.id -eq $currentStory.id }
|
||||
if ($storyToUpdate) {
|
||||
$storyToUpdate.passes = $true
|
||||
$storyToUpdate.notes = "Completed iteration $iterationCount at $(Get-Date -Format 'yyyy-MM-dd HH:mm'). Model: $Model."
|
||||
}
|
||||
Save-Prd $prd
|
||||
|
||||
# Append to progress.txt
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$el = $elapsed.ToString('mm\:ss')
|
||||
$progressEntry = "$ts | PASS | $($currentStory.id): $($currentStory.title) | model=$Model elapsed=$el tools=$toolCount"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
|
||||
Write-Host " [PASSED] $storyLabel" -ForegroundColor Green
|
||||
$noProgressCount = 0
|
||||
$sameErrorCount = 0
|
||||
$lastErrorSignature = ""
|
||||
}
|
||||
elseif ($storyBlocked) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$progressEntry = "$ts | BLOCKED | $storyLabel"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
Write-Host " [BLOCKED] $storyLabel - check $logFile for details." -ForegroundColor Red
|
||||
# Blocked counts as no progress
|
||||
$noProgressCount++
|
||||
}
|
||||
elseif ($storyComplete -and -not $hasGitChanges) {
|
||||
Write-Host " [WARNING] Completion signaled but no git commits. Retrying story." -ForegroundColor DarkYellow
|
||||
$noProgressCount++
|
||||
}
|
||||
elseif ($storyComplete -and -not $typecheckPassed) {
|
||||
Write-Host " [WARNING] Completion signaled but typecheck failed. Retrying story." -ForegroundColor DarkYellow
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$progressEntry = "$ts | TYPECHECK_FAIL | $storyLabel"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
# Has git changes, so not stalled — but not passed either
|
||||
$noProgressCount = 0
|
||||
}
|
||||
else {
|
||||
# No completion signal
|
||||
if ($hasGitChanges) {
|
||||
Write-Host " [PARTIAL] Git changes but no completion signal. Retrying story." -ForegroundColor DarkYellow
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm'
|
||||
$progressEntry = "$ts | PARTIAL | $storyLabel"
|
||||
Add-Content -Path $progressFile -Value $progressEntry -Encoding UTF8
|
||||
$noProgressCount = 0
|
||||
} else {
|
||||
Write-Host " [NO PROGRESS] No changes and no signal." -ForegroundColor DarkYellow
|
||||
$noProgressCount++
|
||||
}
|
||||
}
|
||||
|
||||
# --- Circuit Breaker: No Progress ---
|
||||
|
||||
if ($noProgressCount -ge $MaxNoProgress) {
|
||||
Write-Host ""
|
||||
Write-Host "===== CIRCUIT BREAKER: NO PROGRESS =====" -ForegroundColor Red
|
||||
Write-Host "No meaningful progress for $MaxNoProgress consecutive iterations." -ForegroundColor Red
|
||||
Write-Host "Stuck on: $($currentStory.id) — $($currentStory.title)" -ForegroundColor Red
|
||||
Write-Host "Check $logFile for details." -ForegroundColor Red
|
||||
break
|
||||
}
|
||||
|
||||
# --- Circuit Breaker: Repeated Error ---
|
||||
|
||||
$errorLines = $outputString | Select-String -Pattern "(?i)(error|exception|failed|fatal)[:.].*" -AllMatches
|
||||
if ($errorLines) {
|
||||
$filteredErrors = $errorLines.Matches | Where-Object { $_.Value -notmatch "529|overloaded" } | Select-Object -First 3
|
||||
$currentErrorSignature = ($filteredErrors | ForEach-Object { $_.Value }) -join "|"
|
||||
if ($currentErrorSignature -and $currentErrorSignature -eq $lastErrorSignature) {
|
||||
$sameErrorCount++
|
||||
Write-Host " [Circuit Breaker] Same error pattern repeated ($sameErrorCount/$MaxSameError)" -ForegroundColor DarkYellow
|
||||
if ($sameErrorCount -ge $MaxSameError) {
|
||||
Write-Host ""
|
||||
Write-Host "===== CIRCUIT BREAKER: REPEATED ERROR =====" -ForegroundColor Red
|
||||
Write-Host "Same error for $MaxSameError consecutive iterations:" -ForegroundColor Red
|
||||
Write-Host " $currentErrorSignature" -ForegroundColor Red
|
||||
break
|
||||
}
|
||||
} elseif ($currentErrorSignature) {
|
||||
$sameErrorCount = 0
|
||||
}
|
||||
$lastErrorSignature = $currentErrorSignature
|
||||
} else {
|
||||
$sameErrorCount = 0
|
||||
$lastErrorSignature = ""
|
||||
}
|
||||
|
||||
# --- Dynamic Model Selection ---
|
||||
|
||||
if ($outputString -match "<next-model>(opus|sonnet)</next-model>") {
|
||||
$nextModel = $Matches[1]
|
||||
if ($nextModel -ne $Model) {
|
||||
Write-Host " [Model Switch] $Model -> $nextModel (agent recommendation)" -ForegroundColor Magenta
|
||||
$Model = $nextModel
|
||||
}
|
||||
}
|
||||
|
||||
# Brief pause between iterations
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
} finally {
|
||||
# Cleanup: restore directory, kill dev server
|
||||
Set-Location $originalDir
|
||||
if ($devServerPid) {
|
||||
Write-Host "Stopping dev server (PID $devServerPid)..." -ForegroundColor DarkGray
|
||||
taskkill /T /F /PID $devServerPid 2>$null | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# --- Final Summary ---
|
||||
|
||||
$prd = Read-Prd
|
||||
$finalPassed = @($prd.userStories | Where-Object { $_.passes -eq $true }).Count
|
||||
$finalTotal = $prd.userStories.Count
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
Write-Host " Ralph Loop finished after $iterationCount iteration(s)" -ForegroundColor Cyan
|
||||
Write-Host " Stories: $finalPassed/$finalTotal passed" -ForegroundColor Cyan
|
||||
Write-Host " Branch: $BranchName" -ForegroundColor Cyan
|
||||
Write-Host " Logs: $logDir" -ForegroundColor Cyan
|
||||
Write-Host "===========================================" -ForegroundColor Cyan
|
||||
|
||||
if ($finalPassed -eq $finalTotal) {
|
||||
exit 0
|
||||
} else {
|
||||
exit 1
|
||||
}
|
||||
|
||||
+257
-4
@@ -4,11 +4,11 @@
|
||||
|
||||
### Project Structure
|
||||
- Components in `src/components/`, tiles in `src/components/tiles/`
|
||||
- Old views still in `src/components/views/` (to be removed in Task 21)
|
||||
- Data files in `src/data/` — consultations.ts, medications.ts, problems.ts, investigations.ts, documents.ts, patient.ts + new files: profile.ts, tags.ts, alerts.ts, kpis.ts, skills.ts
|
||||
- Detail renderers in `src/components/detail/` — KPIDetail, ConsultationDetail, SkillDetail, SkillsAllDetail, EducationDetail, ProjectDetail
|
||||
- Data files in `src/data/` — consultations.ts, medications.ts, problems.ts, investigations.ts, documents.ts, patient.ts, tags.ts, alerts.ts, kpis.ts, skills.ts, educationExtras.ts, constellation.ts
|
||||
- Types in `src/types/pmr.ts` (PMR interfaces) and `src/types/index.ts` (Phase type)
|
||||
- Hooks in `src/hooks/` — useScrollCondensation.ts, useBreakpoint.ts
|
||||
- Contexts in `src/contexts/` — AccessibilityContext.tsx (has 1 pre-existing ESLint warning — expected)
|
||||
- Hooks in `src/hooks/` — useActiveSection.ts, useFocusTrap.ts
|
||||
- Contexts in `src/contexts/` — AccessibilityContext.tsx (has 1 pre-existing ESLint warning — expected), DetailPanelContext.tsx (has 1 pre-existing ESLint warning — expected)
|
||||
- Lib in `src/lib/` — search.ts (fuse.js integration)
|
||||
- Path alias: `@/` maps to `./src/`
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
- Types are properly defined in pmr.ts — Consultation, Medication, Problem, Investigation, Document, Patient, ViewId
|
||||
- New types needed: Tag, Alert, KPI, SkillMedication (Task 2)
|
||||
|
||||
### Lucide Icons Typing
|
||||
- Use `LucideIcon` type from `lucide-react` for icon maps, NOT `React.ComponentType<{ size: number }>` — the latter causes TS errors with ForwardRefExoticComponent
|
||||
|
||||
### Known Dependencies
|
||||
- React 18.3.1, TypeScript, Vite
|
||||
- Tailwind CSS for utility classes
|
||||
@@ -610,3 +613,253 @@
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing warning), build ✓
|
||||
**Visual review:** Not applicable — accessibility improvements are non-visual (semantic HTML, ARIA, keyboard nav) except for focus rings which should be tested by user
|
||||
|
||||
### Iteration 19 — US-018: ConsultationDetail renderer (already complete)
|
||||
**Status:** Already implemented by prior iteration — marked as passed
|
||||
**Changes:** None needed — `src/components/detail/ConsultationDetail.tsx` already existed with full implementation (role header, history, achievements, outcomes, coded entries), wired into DetailPanel for both `consultation` and `career-role` types.
|
||||
|
||||
### Iteration 19b — US-020: Create SkillDetail renderer for detail panel
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Created `src/components/detail/SkillDetail.tsx` — narrow panel renderer for individual skills:
|
||||
- Skill header: 20px name, frequency badge (accent-light), status badge (success/neutral)
|
||||
- Category label: 11px uppercase tertiary text (Technical / Healthcare Domain / Strategic & Leadership)
|
||||
- Proficiency bar: 6px height, color-coded (green >=90%, teal >=75%, amber <75%), percentage label
|
||||
- Experience section: large year number (28px) + "years" + "Since YYYY" (Geist Mono)
|
||||
- "Used in" section: lists roles from constellation data (roleSkillMappings), with org-colored dots, role labels, organization + date range
|
||||
- Updated `src/components/DetailPanel.tsx`:
|
||||
- Added import for SkillDetail
|
||||
- Added `content.type === 'skill'` rendering branch
|
||||
- Narrowed placeholder fallback to exclude 'skill' type
|
||||
**Learnings:**
|
||||
- Constellation data provides the skill-to-role mapping via `roleSkillMappings` — filter by skill ID, then look up role nodes for display
|
||||
- Role nodes sorted chronologically (earliest first) gives a natural career progression view
|
||||
- The non-null assertions on `node!` are safe because the `.filter(Boolean)` ensures no nulls
|
||||
- Pre-existing lint error (`_sectionId` in DashboardLayout:64) is unrelated to this work
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — no browser tools available.
|
||||
|
||||
### Iteration 20 — US-021: Create SkillsAllDetail renderer for detail panel
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Created `src/components/detail/SkillsAllDetail.tsx` — narrow panel renderer for full categorised skill list:
|
||||
- Groups all 21 skills by Technical / Healthcare Domain / Strategic & Leadership
|
||||
- Category headers match CoreSkillsTile style: 10px uppercase label + divider line + item count (Geist Mono)
|
||||
- Each skill row: icon container (26px, accent-light), name + frequency/years (Geist Mono), mini proficiency bar (40px wide, color-coded), percentage, chevron
|
||||
- Skill rows clickable → `openPanel({ type: 'skill', skill })` to switch panel to individual SkillDetail
|
||||
- If opened with category filter (from "View all" button), scrolls to and highlights that category (accent-colored header + bottom border)
|
||||
- Hover: border color shift + shadow deepens (matching CoreSkillsTile rows)
|
||||
- Keyboard: Enter/Space triggers skill detail, role="button", tabIndex={0}, descriptive aria-label
|
||||
- Updated `src/components/DetailPanel.tsx`:
|
||||
- Added import for SkillsAllDetail
|
||||
- Added `content.type === 'skills-all'` rendering branch with category prop pass-through
|
||||
- Narrowed placeholder fallback to exclude 'skills-all' type
|
||||
**Learnings:**
|
||||
- Reused the SkillRow pattern from CoreSkillsTile but added a mini proficiency bar instead of status badge — provides more info density in the "view all" context
|
||||
- The `useRef<Record<string, HTMLDivElement | null>>` pattern with callback ref works well for multiple dynamic refs
|
||||
- Category highlight uses both accent-colored text and a 2px bottom border to visually distinguish the filtered category
|
||||
- Pre-existing lint error (`_sectionId` in DashboardLayout:64) continues to be unrelated
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — no browser tools available.
|
||||
|
||||
### Iteration 21 — US-022: Create EducationDetail renderer for detail panel
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Created `src/components/detail/EducationDetail.tsx` — narrow panel renderer for education entries:
|
||||
- Header: type-specific icon (GraduationCap/Award/BookOpen/FlaskConical) + title + institution (purple accent) + duration + classification badge (purple-light bg)
|
||||
- Research Project section: renders `extra.researchDescription` for MPharm entry
|
||||
- OSCE Performance section: renders score in success-colored badge with description
|
||||
- Extracurricular Activities section: bullet list from `extra.extracurriculars`
|
||||
- Programme Overview section: renders `extra.programmeDetail` for Mary Seacole
|
||||
- Notes section: italic secondary text from `document.notes`
|
||||
- All sections use shared `sectionHeaderStyle` (12px uppercase, secondary color, 0.05em tracking)
|
||||
- Updated `src/components/DetailPanel.tsx`:
|
||||
- Added import for EducationDetail
|
||||
- Added `content.type === 'education'` rendering branch
|
||||
- Narrowed placeholder fallback to exclude 'education' type
|
||||
**Learnings:**
|
||||
- Icon type for lucide-react must use `LucideIcon` type, not `React.ComponentType<{ size: number }>` — the latter causes type incompatibility with ForwardRefExoticComponent
|
||||
- The `educationExtras` data matches documents by `documentId` field — currently only MPharm and Mary Seacole have extras
|
||||
- Purple color (#7C3AED) is used consistently for education across the app (dot colors in CardHeader, CareerActivity, and now EducationDetail institution text and classification badge)
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — no browser tools available.
|
||||
|
||||
### Iteration 22 — US-023: Install D3 and scaffold CareerConstellation component
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Installed `d3` and `@types/d3` npm packages (70 packages added)
|
||||
- Created `src/components/CareerConstellation.tsx` — scaffolded component with:
|
||||
- Props: `onRoleClick(id)` and `onSkillClick(id)` stored in callbacksRef for future D3 event binding
|
||||
- Responsive SVG container using ResizeObserver: 400px desktop, 300px tablet (<1024px), 250px mobile (<768px)
|
||||
- viewBox matches actual dimensions for responsive scaling
|
||||
- Radial gradient background: `#F0F5F4` (--bg-dashboard) center → `#FFFFFF` (--surface) edge, rx=6
|
||||
- Placeholder text showing node/link counts from constellation data (Geist Mono, tertiary color)
|
||||
- Container with border-radius and overflow hidden
|
||||
- SVG has `role="img"` and `aria-label` for accessibility
|
||||
- Imperative SVG drawing via useEffect on svgRef (matches ECG pattern for D3 compatibility)
|
||||
**Learnings:**
|
||||
- `callbacksRef` pattern stores click handlers in a ref for D3 imperative code — avoids stale closures when D3 attaches event listeners in US-024/026
|
||||
- ResizeObserver provides cleaner responsive behavior than CSS media queries for SVG — container width determines height tier
|
||||
- The SVG namespace `http://www.w3.org/2000/svg` is required for createElement in imperative SVG building
|
||||
- D3 is installed but not yet imported — US-024 will use `d3.forceSimulation` etc. on the svgRef
|
||||
- Pre-existing lint error (`_sectionId` in DashboardLayout:64) continues to be unrelated
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — component not yet integrated into CareerActivityTile (will be wired in US-026).
|
||||
|
||||
### Iteration 23 — US-024: Build D3 force-directed graph rendering in CareerConstellation
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Rewrote `src/components/CareerConstellation.tsx` to use D3 force simulation:
|
||||
- Replaced imperative SVG createElement with D3 selections (`d3.select`, `.selectAll`, `.join`)
|
||||
- D3 force simulation with: `forceManyBody(-200)`, `forceLink(distance 80, strength from data * 0.5)`, `forceX` chronological (roles positioned left-to-right by `startYear` via `d3.scaleLinear`), `forceY` centered at `height/2`, `forceCollide` (30 for roles, 14 for skills)
|
||||
- Role nodes: 24px radius circles filled with `orgColor`, 2px white stroke, 8px white `shortLabel` text centered
|
||||
- Skill nodes: 10px radius circles, color-coded by domain (clinical=#059669 green, technical=#0D6E6E teal, leadership=#D97706 amber), 1.5px white stroke, opacity 0.85
|
||||
- Skill labels: 9px Geist Mono text below each skill node (using `shortLabel`)
|
||||
- Links: 1px `#D4E0DE` lines at opacity 0.3
|
||||
- Node positions constrained within SVG bounds on each tick
|
||||
- Layered rendering: links group below nodes group
|
||||
- `simulationRef` stores active simulation, stopped on cleanup or dimension change
|
||||
- Preserved existing ResizeObserver responsive height (400/300/250px)
|
||||
- Preserved radial gradient background, `role="img"`, `aria-label`
|
||||
- Removed unused `ConstellationLink` type import (caught by typecheck)
|
||||
**Learnings:**
|
||||
- D3 `forceLink.strength()` receives the link object — cast to `SimLink` to access `.strength` field
|
||||
- Role `forceX` uses strong pull (0.8) to maintain chronological layout; skill `forceX` uses weak pull (0.05) to let links drive position
|
||||
- `forceCollide` radius should be slightly larger for skills than their visual radius to prevent label overlap
|
||||
- The `SimNode` interface extending `ConstellationNode` with `x/y/vx/vy/fx/fy` satisfies D3's `SimulationNodeDatum` needs
|
||||
- Pre-existing lint issues: `_sectionId` error + 2 context warnings — all unrelated
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — component not yet wired into CareerActivityTile (US-026). D3 simulation verified via successful build.
|
||||
|
||||
### Iteration 24 — US-025: Add accessibility to CareerConstellation
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Updated `src/components/CareerConstellation.tsx` with four accessibility features:
|
||||
- **Screen-reader description**: `buildScreenReaderDescription()` generates a hidden `<p>` (sr-only via clip rect) describing all 5 roles, their organizations, year ranges, and associated skills from `roleSkillMappings`
|
||||
- **Keyboard navigation**: Hidden `<button>` elements overlaid on the SVG container, one per role node. Tab navigates through roles, Enter/Space triggers `onRoleClick`. Each button has descriptive `aria-label` (role name, org, year range)
|
||||
- **Focus indicators**: SVG `.focus-ring` circle (ROLE_RADIUS + 4px) rendered behind each role node. Transparent by default, becomes teal `#0D6E6E` stroke when the corresponding hidden button receives focus (tracked via `focusedNodeId` state + `useEffect` on D3 selection)
|
||||
- **prefers-reduced-motion**: When enabled, simulation runs 300 ticks synchronously (`simulation.stop()` + loop), then renders final positions immediately — no animation frames. Uses the established module-scope `matchMedia` check pattern
|
||||
- Imported `roleSkillMappings` from constellation data for SR description
|
||||
- Added `useCallback` for `handleNodeKeyDown` to prevent re-renders
|
||||
**Learnings:**
|
||||
- D3 focus indicators work via a dual approach: hidden HTML buttons for actual keyboard focus, plus D3-drawn SVG circles that respond to React state changes — avoids fighting D3's imperative model with React's declarative focus management
|
||||
- Running `simulation.tick()` in a loop (300 iterations) is sufficient to reach stable positions for this graph size (5 roles + 21 skills)
|
||||
- The `.focus-ring` circle must be appended before the main circle in the SVG group to render behind it (SVG painting order = DOM order)
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — not yet wired into CareerActivityTile (US-026).
|
||||
|
||||
### Iteration 25 — US-026: Add hover and click interactions to CareerConstellation
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Updated `src/components/CareerConstellation.tsx` with three interaction features:
|
||||
- **Hover highlighting**: Built adjacency map from `constellationLinks`. On `mouseenter`, non-connected nodes fade to 0.15 opacity. Connected links brighten to teal (`#0D6E6E`), thicken to 2px, increase opacity to 0.7. Non-connected links dim to 0.1 opacity. Role hover also scales connected skill nodes up (+3px radius) via D3 transition (150ms).
|
||||
- **Hover reset**: On `mouseleave`, all nodes reset to full opacity, skill circles return to `SKILL_RADIUS`, links return to default stroke/opacity/width.
|
||||
- **Click handlers**: Click on any node calls `callbacksRef.current.onRoleClick(id)` or `onSkillClick(id)` via the existing callbacksRef pattern (avoids stale closures).
|
||||
- Added `.node-circle` and `.node-label` classes to circles/text for targeted D3 selections during hover
|
||||
- Updated `src/components/tiles/CareerActivityTile.tsx`:
|
||||
- Replaced placeholder `<div>` with actual `<CareerConstellation>` component
|
||||
- Added `handleRoleClick(roleId)` → finds consultation by ID → `openPanel({ type: 'career-role', consultation })`
|
||||
- Added `handleSkillClick(skillId)` → finds skill by ID → `openPanel({ type: 'skill', skill })`
|
||||
- Refactored `handleItemClick` to delegate to `handleRoleClick` for consistency
|
||||
- Imported `skills` from `@/data/skills` and `CareerConstellation` from `../CareerConstellation`
|
||||
**Learnings:**
|
||||
- D3 hover uses `mouseenter`/`mouseleave` (not `mouseover`/`mouseout`) to avoid bubbling issues with nested SVG groups
|
||||
- The adjacency map uses source/target strings from `constellationLinks` (pre-simulation), not SimNode objects — link data gets resolved by D3 after forceLink runs, so during hover the source/target may be either string or SimNode objects. The click/hover handlers check both forms.
|
||||
- The `callbacksRef` pattern established in US-023 works perfectly for D3 click events — no stale closures
|
||||
- Pre-existing lint issues: `_sectionId` error + 2 context warnings — all unrelated
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — no browser tools available.
|
||||
|
||||
### Iteration 26 — US-027: Restyle LoginScreen with teal accents
|
||||
**Status:** Complete
|
||||
**Changes:**
|
||||
- Updated `src/components/LoginScreen.tsx`:
|
||||
- Replaced all `#005EB8` (NHS Blue) with `#0D6E6E` (teal accent): shield icon color, active field borders, cursor color, button default bg, focus ring
|
||||
- Replaced `#004D9F` (hover) with `#0A8080` (teal hover)
|
||||
- Replaced `#004494` (pressed) with `#085858` (teal pressed)
|
||||
- Background color: `#1E293B` → `#1A2B2A` (warmer, cohesive with dashboard palette)
|
||||
- Shield icon container: `rgba(0, 94, 184, 0.07)` → `rgba(13, 110, 110, 0.08)` (teal-tinted)
|
||||
**Learnings:**
|
||||
- LoginScreen had 6 instances of `#005EB8` — all replaced for consistency
|
||||
- The background change from `#1E293B` (slate) to `#1A2B2A` (dark teal-green) creates visual cohesion with the teal accent palette
|
||||
- Button states follow the teal gradient: default #0D6E6E → hover #0A8080 → pressed #085858 (progressively darker)
|
||||
**Quality checks:** typecheck ✓, lint ✓ (1 pre-existing error + 2 warnings), build ✓
|
||||
**Visual review:** Skipped — no browser tools available.
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-14 - US-028
|
||||
- **What was implemented:** Changed login username from A.CHARLWOOD to a.recruiter, added connection status indicator with red→green transition, updated button disabled logic to require both typing complete AND connection established.
|
||||
- **Files changed:**
|
||||
- `src/components/LoginScreen.tsx` — new `connectionState` state, connection timer (2000ms), connection status indicator UI (6px dot + Geist Mono text), `canLogin` derived state replacing `typingComplete` for button control
|
||||
- `src/components/DashboardLayout.tsx` — fixed pre-existing lint error (unused `_sectionId` parameter, added eslint-disable comment)
|
||||
- **Learnings for future iterations:**
|
||||
- The DashboardLayout had a pre-existing lint error with `_sectionId` — ESLint config doesn't respect underscore-prefix unused var convention, needed `eslint-disable-next-line` comment. TypeScript `tsc -b` (used in build) DOES respect underscore prefix though.
|
||||
- Connection status uses CSS `transition: 300ms` for the color change — matches the spec for smooth dot/text color transition
|
||||
- `canLogin` is a derived value (not state) combining `typingComplete && connectionState === 'connected'` — cleaner than adding another state variable
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-14 - US-029
|
||||
- **What was implemented:** Added post-login loading state with CSS spinner (~600ms) that replaces the login card content after clicking Log In. Updated TopBar session display name from "Dr. A.CHARLWOOD" to "A.RECRUITER".
|
||||
- **Files changed:**
|
||||
- `src/components/LoginScreen.tsx` — new `isLoading` state, handleLogin now sets isLoading before isExiting, card content conditionally renders either login form or spinner + "Loading clinical records..." text. Spinner uses CSS `login-spin` animation.
|
||||
- `src/components/TopBar.tsx` — changed session name from "Dr. A.CHARLWOOD" to "A.RECRUITER"
|
||||
- `src/index.css` — added `@keyframes login-spin` and `.login-spinner` class, plus `prefers-reduced-motion` override (static indicator, no spin)
|
||||
- **Learnings for future iterations:**
|
||||
- The loading state replaces card content via conditional rendering (`isLoading ? spinner : form`) rather than an overlay — keeps the card dimensions stable
|
||||
- The sequence is: buttonPressed (100ms) → isLoading (600ms) → isExiting (200ms) → onComplete. With reduced motion, loading and exit delays are 0ms.
|
||||
- Spinner uses pure CSS animation (`border-top-color` trick) — no library needed
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-14 - US-030
|
||||
- **What was implemented:** Updated CommandPalette search index to include all 21 skills (not just 5), added `panel` action type to PaletteAction union, and wired skill/KPI/project palette results to open detail panels directly.
|
||||
- **Files changed:**
|
||||
- `src/lib/search.ts` — Added `panel` action type with `DetailPanelContent` payload. Skills section now iterates all 21 skills from `skills.ts` (was hardcoded to 5). Project results find matching `Investigation` by ID and use `panel` action. Achievement results find matching `KPI` by ID and use `panel` action. Imported `kpis` and `DetailPanelContent` type.
|
||||
- `src/components/DashboardLayout.tsx` — Added `panel` case to `handlePaletteAction` switch that calls `openPanel(action.panelContent)`. Imported `useDetailPanel` from context.
|
||||
- **Learnings for future iterations:**
|
||||
- The `panel` action type carries a full `DetailPanelContent` discriminated union payload — this means any palette item can open any detail panel type without intermediate mapping
|
||||
- Achievement "Team of 12 Led" was updated to "1.2M Population Served" to match the KPI data change from US-006
|
||||
- For projects, a fallback to `scroll` action is used when the investigation ID doesn't match — defensive pattern for data mismatches
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-14 - US-031
|
||||
- **What was implemented:** Responsive testing and fixes for all new components. Audited DetailPanel, SubNav, CareerConstellation, dashboard grid, CoreSkillsTile, touch targets, and 375px overflow.
|
||||
- **Files changed:**
|
||||
- `src/components/SubNav.tsx` — Added `overflowX: auto`, `scrollbarWidth: 'none'`, horizontal padding, `flexShrink: 0` on tab buttons, `minHeight: 36px` for touch targets, flex layout for vertical centering
|
||||
- `src/index.css` — Added `.subnav-scroll::-webkit-scrollbar { display: none }` for WebKit scrollbar hiding
|
||||
- `src/components/DetailPanel.tsx` — Enlarged close button from 32x32px to 44x44px for mobile touch target compliance
|
||||
- `src/components/tiles/CoreSkillsTile.tsx` — Added `minHeight: 44px` to SkillRow and "View all" button for touch target compliance
|
||||
- `src/components/tiles/ProjectsTile.tsx` — Added `minHeight: 44px` to ProjectItem for touch target compliance
|
||||
- `src/components/tiles/LastConsultationTile.tsx` — Added `minHeight: 44px` to "View full record" button
|
||||
- **Audit results (already passing):**
|
||||
- DetailPanel: `@media (max-width: 767px)` already set both widths to 100vw ✓
|
||||
- CareerConstellation: `getHeight()` already returns 400/300/250px by breakpoint ✓
|
||||
- Dashboard grid: mobile-first 1fr → 2fr at 768px, KPIs + Projects stack correctly ✓
|
||||
- CoreSkillsTile: `full` prop spans both columns at all breakpoints ✓
|
||||
- No horizontal overflow at 375px: TopBar search hidden <768px, no problematic nowrap on wide content ✓
|
||||
- **Learnings for future iterations:**
|
||||
- `scrollbarWidth: 'none'` (Firefox) + `::-webkit-scrollbar { display: none }` (Chrome/Safari) together hide scrollbars cross-browser
|
||||
- WCAG touch target minimum is 44x44px — check all `role="button"`, `<button>`, and clickable elements
|
||||
- SubNav at 375px has ~345px available (375 - 2*16px padding) — 5 short labels with 24px gaps fit without scroll, but the scroll fallback is good insurance
|
||||
|
||||
## 2026-02-14 — US-032
|
||||
- **What was implemented:** Reduced motion audit, final cleanup, and visual review
|
||||
- **Files changed:**
|
||||
- `src/index.css` — Added prefers-reduced-motion overrides for SubNav button transitions and smooth scroll behavior. Removed 18 unused `--pmr-*` legacy CSS variables and `.pmr-theme` utility class.
|
||||
- `src/components/LoginScreen.tsx` — Connection status dot and text transitions now respect `prefersReducedMotion` (instant when enabled).
|
||||
- `src/components/detail/ProjectDetail.tsx` — Created missing ProjectDetail renderer (project name, year, status badge, methodology, tech stack tags, results bullets, external link button).
|
||||
- `src/components/DetailPanel.tsx` — Wired ProjectDetail for `content.type === 'project'`. Removed placeholder fallback (all content types now have renderers).
|
||||
- Deleted `src/hooks/useBreakpoint.ts` (unused)
|
||||
- Deleted `src/data/profile.ts` (unused — PatientSummaryTile has profile text hardcoded)
|
||||
- **Learnings for future iterations:**
|
||||
- ProjectDetail was missing despite US-019 being marked as passed — always verify file existence, not just PRD status
|
||||
- `profile.ts` was created but never imported — PatientSummaryTile hardcodes the profile text instead
|
||||
- `useBreakpoint.ts` was orphaned after its consumers were deleted in US-001
|
||||
- Legacy `--pmr-*` CSS variables were all superseded by the new design token system and safe to remove
|
||||
- `pmr-scrollbar` class is still actively used (Sidebar, DashboardLayout, CommandPalette) — do not remove
|
||||
- SubNav inline transitions need CSS `!important` override in prefers-reduced-motion since they're set via inline styles
|
||||
- The `html { scroll-behavior: smooth }` also needs a reduced-motion override to `auto`
|
||||
---
|
||||
|
||||
|
||||
Generated
+719
@@ -8,6 +8,8 @@
|
||||
"name": "andy-charlwood-cv",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
@@ -1467,6 +1469,259 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1474,6 +1729,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2190,6 +2451,416 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2215,6 +2886,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -2737,6 +3417,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2774,6 +3466,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -3585,6 +4286,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
@@ -3654,6 +4361,18 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.3",
|
||||
"d3": "^7.9.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
+6
-1
@@ -5,6 +5,7 @@ import { ECGAnimation } from './components/ECGAnimation'
|
||||
import { LoginScreen } from './components/LoginScreen'
|
||||
import { DashboardLayout } from './components/DashboardLayout'
|
||||
import { AccessibilityProvider } from './contexts/AccessibilityContext'
|
||||
import { DetailPanelProvider } from './contexts/DetailPanelContext'
|
||||
|
||||
function SkipButton({ onSkip }: { onSkip: () => void }) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -76,7 +77,11 @@ function App() {
|
||||
<LoginScreen onComplete={() => setPhase('pmr')} />
|
||||
)}
|
||||
|
||||
{phase === 'pmr' && <DashboardLayout />}
|
||||
{phase === 'pmr' && (
|
||||
<DetailPanelProvider>
|
||||
<DashboardLayout />
|
||||
</DetailPanelProvider>
|
||||
)}
|
||||
|
||||
{(phase === 'boot' || phase === 'ecg') && (
|
||||
<SkipButton onSkip={skipToLogin} />
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
|
||||
interface BreadcrumbProps {
|
||||
currentView: ViewId
|
||||
expandedItem?: {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
onNavigateToView?: (view: ViewId) => void
|
||||
onCollapseItem?: () => void
|
||||
}
|
||||
|
||||
const viewLabels: Record<ViewId, string> = {
|
||||
summary: 'Summary',
|
||||
consultations: 'Experience',
|
||||
medications: 'Skills',
|
||||
problems: 'Achievements',
|
||||
investigations: 'Projects',
|
||||
documents: 'Education',
|
||||
referrals: 'Contact',
|
||||
}
|
||||
|
||||
export function Breadcrumb({
|
||||
currentView,
|
||||
expandedItem,
|
||||
onNavigateToView,
|
||||
onCollapseItem,
|
||||
}: BreadcrumbProps) {
|
||||
const handleNavigateToPatientRecord = () => {
|
||||
if (onNavigateToView) {
|
||||
onNavigateToView('summary')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigateToCurrentView = () => {
|
||||
if (onCollapseItem) {
|
||||
onCollapseItem()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex items-center gap-2 mb-6"
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<ol className="flex items-center gap-2">
|
||||
{/* Patient Record (root) */}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToPatientRecord}
|
||||
className="text-[13px] font-ui font-normal text-gray-400 hover:text-pmr-nhsblue transition-colors"
|
||||
>
|
||||
Patient Record
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight size={14} className="text-gray-300" />
|
||||
</li>
|
||||
|
||||
{/* Current view */}
|
||||
<li>
|
||||
{expandedItem ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToCurrentView}
|
||||
className="text-[13px] font-ui font-normal text-gray-400 hover:text-pmr-nhsblue transition-colors"
|
||||
>
|
||||
{viewLabels[currentView]}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
|
||||
{viewLabels[currentView]}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{/* Expanded item (if any) */}
|
||||
{expandedItem && (
|
||||
<>
|
||||
<li aria-hidden="true">
|
||||
<ChevronRight size={14} className="text-gray-300" />
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-[13px] font-ui font-normal text-gray-600" aria-current="page">
|
||||
{expandedItem.name}
|
||||
</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function Card({ children, full, className, tileId }: CardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
export interface CardHeaderProps {
|
||||
dotColor: 'teal' | 'amber' | 'green' | 'alert' | 'purple'
|
||||
title: string
|
||||
rightText?: string
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
|
||||
import type { ConstellationNode } from '@/types/pmr'
|
||||
|
||||
interface CareerConstellationProps {
|
||||
onRoleClick: (id: string) => void
|
||||
onSkillClick: (id: string) => void
|
||||
}
|
||||
|
||||
const DESKTOP_HEIGHT = 400
|
||||
const TABLET_HEIGHT = 300
|
||||
const MOBILE_HEIGHT = 250
|
||||
|
||||
const ROLE_RADIUS = 24
|
||||
const SKILL_RADIUS = 10
|
||||
const COLLIDE_RADIUS = 30
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const domainColorMap: Record<string, string> = {
|
||||
clinical: '#059669',
|
||||
technical: '#0D6E6E',
|
||||
leadership: '#D97706',
|
||||
}
|
||||
|
||||
function getHeight(width: number): number {
|
||||
if (width < 768) return MOBILE_HEIGHT
|
||||
if (width < 1024) return TABLET_HEIGHT
|
||||
return DESKTOP_HEIGHT
|
||||
}
|
||||
|
||||
interface SimNode extends ConstellationNode {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
}
|
||||
|
||||
interface SimLink {
|
||||
source: SimNode | string
|
||||
target: SimNode | string
|
||||
strength: number
|
||||
}
|
||||
|
||||
function buildScreenReaderDescription(): string {
|
||||
const roleNodes = constellationNodes.filter(n => n.type === 'role')
|
||||
const skillNodes = constellationNodes.filter(n => n.type === 'skill')
|
||||
|
||||
const roleDescriptions = roleNodes.map(role => {
|
||||
const mapping = roleSkillMappings.find(m => m.roleId === role.id)
|
||||
const skillNames = mapping
|
||||
? mapping.skillIds
|
||||
.map(sid => skillNodes.find(s => s.id === sid)?.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: ''
|
||||
const yearRange = role.endYear
|
||||
? `${role.startYear}–${role.endYear}`
|
||||
: `${role.startYear}–present`
|
||||
return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}`
|
||||
})
|
||||
|
||||
return `Career constellation graph with ${roleNodes.length} roles and ${skillNodes.length} skills. ` +
|
||||
roleDescriptions.join('. ') + '.'
|
||||
}
|
||||
|
||||
const CareerConstellation: React.FC<CareerConstellationProps> = ({
|
||||
onRoleClick,
|
||||
onSkillClick,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: DESKTOP_HEIGHT })
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
|
||||
|
||||
const callbacksRef = useRef({ onRoleClick, onSkillClick })
|
||||
callbacksRef.current = { onRoleClick, onSkillClick }
|
||||
|
||||
const roleNodes = constellationNodes.filter(n => n.type === 'role')
|
||||
const srDescription = buildScreenReaderDescription()
|
||||
|
||||
const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (nodeType === 'role') {
|
||||
onRoleClick(nodeId)
|
||||
} else {
|
||||
onSkillClick(nodeId)
|
||||
}
|
||||
}
|
||||
}, [onRoleClick, onSkillClick])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateDimensions = () => {
|
||||
const width = container.clientWidth
|
||||
const height = getHeight(width)
|
||||
setDimensions({ width, height })
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
|
||||
const observer = new ResizeObserver(updateDimensions)
|
||||
observer.observe(container)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const svg = d3.select(svgRef.current)
|
||||
if (!svgRef.current) return
|
||||
|
||||
const { width, height } = dimensions
|
||||
|
||||
if (simulationRef.current) {
|
||||
simulationRef.current.stop()
|
||||
}
|
||||
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
// Defs with radial gradient
|
||||
const defs = svg.append('defs')
|
||||
const gradient = defs.append('radialGradient')
|
||||
.attr('id', 'constellation-bg')
|
||||
.attr('cx', '50%')
|
||||
.attr('cy', '50%')
|
||||
.attr('r', '60%')
|
||||
gradient.append('stop').attr('offset', '0%').attr('stop-color', '#F0F5F4')
|
||||
gradient.append('stop').attr('offset', '100%').attr('stop-color', '#FFFFFF')
|
||||
|
||||
// Background rect
|
||||
svg.append('rect')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('fill', 'url(#constellation-bg)')
|
||||
.attr('rx', 6)
|
||||
|
||||
// Prepare data
|
||||
const nodes: SimNode[] = constellationNodes.map(n => ({
|
||||
...n,
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}))
|
||||
|
||||
const links: SimLink[] = constellationLinks.map(l => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
strength: l.strength,
|
||||
}))
|
||||
|
||||
const simRoleNodes = nodes.filter(n => n.type === 'role')
|
||||
const years = simRoleNodes.map(n => n.startYear ?? 2016)
|
||||
const minYear = Math.min(...years)
|
||||
const maxYear = Math.max(...years)
|
||||
const padding = 80
|
||||
|
||||
const xScale = d3.scaleLinear()
|
||||
.domain([minYear, maxYear])
|
||||
.range([padding, width - padding])
|
||||
|
||||
const linkGroup = svg.append('g').attr('class', 'links')
|
||||
const nodeGroup = svg.append('g').attr('class', 'nodes')
|
||||
|
||||
const linkSelection = linkGroup.selectAll('line')
|
||||
.data(links)
|
||||
.join('line')
|
||||
.attr('stroke', '#D4E0DE')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 0.3)
|
||||
|
||||
const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
|
||||
.data(nodes)
|
||||
.join('g')
|
||||
.attr('class', d => `node node-${d.type}`)
|
||||
.style('cursor', 'pointer')
|
||||
.attr('data-node-id', d => d.id)
|
||||
|
||||
// Role nodes: large circles with focus ring support
|
||||
nodeSelection.filter(d => d.type === 'role')
|
||||
.append('circle')
|
||||
.attr('class', 'focus-ring')
|
||||
.attr('r', ROLE_RADIUS + 4)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', 'transparent')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'role')
|
||||
.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('r', ROLE_RADIUS)
|
||||
.attr('fill', d => d.orgColor ?? '#0D6E6E')
|
||||
.attr('stroke', '#FFFFFF')
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'role')
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('fill', '#FFFFFF')
|
||||
.attr('font-size', '8')
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-family', 'var(--font-ui)')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => d.shortLabel ?? d.label.slice(0, 8))
|
||||
|
||||
// Skill nodes
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('circle')
|
||||
.attr('class', 'node-circle')
|
||||
.attr('r', SKILL_RADIUS)
|
||||
.attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
|
||||
.attr('stroke', '#FFFFFF')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('fill-opacity', 0.85)
|
||||
|
||||
nodeSelection.filter(d => d.type === 'skill')
|
||||
.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', SKILL_RADIUS + 12)
|
||||
.attr('fill', '#5B7A78')
|
||||
.attr('font-size', '9')
|
||||
.attr('font-family', 'var(--font-geist-mono)')
|
||||
.attr('pointer-events', 'none')
|
||||
.text(d => d.shortLabel ?? d.label)
|
||||
|
||||
// Build adjacency lookup for hover interactions
|
||||
const connectedMap = new Map<string, Set<string>>()
|
||||
constellationLinks.forEach(l => {
|
||||
if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
|
||||
if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
|
||||
connectedMap.get(l.source)!.add(l.target)
|
||||
connectedMap.get(l.target)!.add(l.source)
|
||||
})
|
||||
|
||||
const HOVER_TRANSITION = '150ms'
|
||||
|
||||
// Hover interactions
|
||||
nodeSelection.on('mouseenter', function(_event, d) {
|
||||
const connected = connectedMap.get(d.id) ?? new Set()
|
||||
|
||||
// Dim non-connected nodes
|
||||
nodeSelection
|
||||
.style('transition', `opacity ${HOVER_TRANSITION}`)
|
||||
.style('opacity', n => {
|
||||
if (n.id === d.id) return '1'
|
||||
if (connected.has(n.id)) return '1'
|
||||
return '0.15'
|
||||
})
|
||||
|
||||
// Scale up connected skill nodes when hovering a role
|
||||
if (d.type === 'role') {
|
||||
nodeSelection.filter(n => n.type === 'skill' && connected.has(n.id))
|
||||
.select('.node-circle')
|
||||
.transition().duration(150)
|
||||
.attr('r', SKILL_RADIUS + 3)
|
||||
}
|
||||
|
||||
// Brighten connected links, dim others
|
||||
linkSelection
|
||||
.style('transition', `stroke-opacity ${HOVER_TRANSITION}, stroke ${HOVER_TRANSITION}`)
|
||||
.attr('stroke', l => {
|
||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
||||
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||
if (src === d.id || tgt === d.id) return '#0D6E6E'
|
||||
return '#D4E0DE'
|
||||
})
|
||||
.attr('stroke-opacity', l => {
|
||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
||||
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||
if (src === d.id || tgt === d.id) return 0.7
|
||||
return 0.1
|
||||
})
|
||||
.attr('stroke-width', l => {
|
||||
const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
|
||||
const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
|
||||
if (src === d.id || tgt === d.id) return 2
|
||||
return 1
|
||||
})
|
||||
})
|
||||
|
||||
nodeSelection.on('mouseleave', function() {
|
||||
// Reset all nodes
|
||||
nodeSelection
|
||||
.style('opacity', '1')
|
||||
|
||||
// Reset skill node sizes
|
||||
nodeSelection.filter(n => n.type === 'skill')
|
||||
.select('.node-circle')
|
||||
.transition().duration(150)
|
||||
.attr('r', SKILL_RADIUS)
|
||||
|
||||
// Reset all links
|
||||
linkSelection
|
||||
.attr('stroke', '#D4E0DE')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-opacity', 0.3)
|
||||
})
|
||||
|
||||
// Click interactions
|
||||
nodeSelection.on('click', function(_event, d) {
|
||||
if (d.type === 'role') {
|
||||
callbacksRef.current.onRoleClick(d.id)
|
||||
} else {
|
||||
callbacksRef.current.onSkillClick(d.id)
|
||||
}
|
||||
})
|
||||
|
||||
// Force simulation
|
||||
const simulation = d3.forceSimulation<SimNode>(nodes)
|
||||
.force('charge', d3.forceManyBody<SimNode>().strength(-200))
|
||||
.force('link', d3.forceLink<SimNode, SimLink>(links)
|
||||
.id(d => d.id)
|
||||
.distance(80)
|
||||
.strength(d => (d as SimLink).strength * 0.5))
|
||||
.force('x', d3.forceX<SimNode>(d => {
|
||||
if (d.type === 'role' && d.startYear != null) {
|
||||
return xScale(d.startYear)
|
||||
}
|
||||
return width / 2
|
||||
}).strength(d => d.type === 'role' ? 0.8 : 0.05))
|
||||
.force('y', d3.forceY<SimNode>(height / 2).strength(0.3))
|
||||
.force('collide', d3.forceCollide<SimNode>(d =>
|
||||
d.type === 'role' ? COLLIDE_RADIUS : SKILL_RADIUS + 4
|
||||
))
|
||||
|
||||
simulationRef.current = simulation
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
// Run simulation to completion synchronously — no animation
|
||||
simulation.stop()
|
||||
for (let i = 0; i < 300; i++) {
|
||||
simulation.tick()
|
||||
}
|
||||
|
||||
// Constrain and render final positions
|
||||
nodes.forEach(d => {
|
||||
const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS
|
||||
d.x = Math.max(r, Math.min(width - r, d.x))
|
||||
d.y = Math.max(r, Math.min(height - r, d.y))
|
||||
})
|
||||
|
||||
linkSelection
|
||||
.attr('x1', d => (d.source as SimNode).x)
|
||||
.attr('y1', d => (d.source as SimNode).y)
|
||||
.attr('x2', d => (d.target as SimNode).x)
|
||||
.attr('y2', d => (d.target as SimNode).y)
|
||||
|
||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
} else {
|
||||
simulation.on('tick', () => {
|
||||
nodes.forEach(d => {
|
||||
const r = d.type === 'role' ? ROLE_RADIUS : SKILL_RADIUS
|
||||
d.x = Math.max(r, Math.min(width - r, d.x))
|
||||
d.y = Math.max(r, Math.min(height - r, d.y))
|
||||
})
|
||||
|
||||
linkSelection
|
||||
.attr('x1', d => (d.source as SimNode).x)
|
||||
.attr('y1', d => (d.source as SimNode).y)
|
||||
.attr('x2', d => (d.target as SimNode).x)
|
||||
.attr('y2', d => (d.target as SimNode).y)
|
||||
|
||||
nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [dimensions])
|
||||
|
||||
// Update focus ring when focusedNodeId changes
|
||||
useEffect(() => {
|
||||
if (!svgRef.current) return
|
||||
const svg = d3.select(svgRef.current)
|
||||
|
||||
// Reset all focus rings
|
||||
svg.selectAll('.focus-ring')
|
||||
.attr('stroke', 'transparent')
|
||||
|
||||
// Highlight focused node
|
||||
if (focusedNodeId) {
|
||||
svg.selectAll<SVGGElement, SimNode>('g.node')
|
||||
.filter(d => d.id === focusedNodeId)
|
||||
.select('.focus-ring')
|
||||
.attr('stroke', '#0D6E6E')
|
||||
}
|
||||
}, [focusedNodeId])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
||||
role="img"
|
||||
aria-label="Career constellation showing roles and skills across career timeline"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
{/* Screen-reader-only description */}
|
||||
<p
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0,0,0,0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{srDescription}
|
||||
</p>
|
||||
{/* Keyboard-navigable role buttons (visually hidden, positioned over SVG) */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Career roles — use Tab to navigate, Enter to view details"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{roleNodes.map(role => {
|
||||
const yearRange = role.endYear
|
||||
? `${role.startYear}–${role.endYear}`
|
||||
: `${role.startYear}–present`
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
type="button"
|
||||
aria-label={`${role.label} at ${role.organization}, ${yearRange}. Press Enter to view details.`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 48,
|
||||
height: 48,
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
padding: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
onFocus={() => setFocusedNodeId(role.id)}
|
||||
onBlur={() => setFocusedNodeId(null)}
|
||||
onClick={() => onRoleClick(role.id)}
|
||||
onKeyDown={e => handleNodeKeyDown(e, role.id, 'role')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CareerConstellation
|
||||
@@ -1,406 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
Pill,
|
||||
AlertTriangle,
|
||||
FlaskConical,
|
||||
FolderOpen,
|
||||
Send,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
import { buildLegacySearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
|
||||
import type { FuseResult } from 'fuse.js'
|
||||
|
||||
interface NavItem {
|
||||
id: ViewId
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
interface ClinicalSidebarProps {
|
||||
activeView: ViewId
|
||||
onViewChange: (view: ViewId) => void
|
||||
isTablet?: boolean
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'summary', label: 'Summary', icon: <ClipboardList size={18} /> },
|
||||
{ id: 'consultations', label: 'Experience', icon: <FileText size={18} /> },
|
||||
{ id: 'medications', label: 'Skills', icon: <Pill size={18} /> },
|
||||
{ id: 'problems', label: 'Achievements', icon: <AlertTriangle size={18} /> },
|
||||
{ id: 'investigations', label: 'Projects', icon: <FlaskConical size={18} /> },
|
||||
{ id: 'documents', label: 'Education', icon: <FolderOpen size={18} /> },
|
||||
{ id: 'referrals', label: 'Contact', icon: <Send size={18} /> },
|
||||
]
|
||||
|
||||
function getCurrentTime(): string {
|
||||
const now = new Date()
|
||||
return now.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: ClinicalSidebarProps) {
|
||||
const [currentTime, setCurrentTime] = useState(getCurrentTime)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false)
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
|
||||
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
|
||||
const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
|
||||
|
||||
// Build search index once on mount
|
||||
const searchIndex = useMemo(() => buildLegacySearchIndex(), [])
|
||||
|
||||
const handleNavClick = useCallback(
|
||||
(view: ViewId) => {
|
||||
onViewChange(view)
|
||||
window.location.hash = view
|
||||
},
|
||||
[onViewChange]
|
||||
)
|
||||
|
||||
const handleNavKeyDown = useCallback((e: React.KeyboardEvent, index: number) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (index < navItems.length - 1) {
|
||||
setFocusedIndex(index + 1)
|
||||
navButtonRefs.current[index + 1]?.focus()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (index > 0) {
|
||||
setFocusedIndex(index - 1)
|
||||
navButtonRefs.current[index - 1]?.focus()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
handleNavClick(navItems[index].id)
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setFocusedIndex(0)
|
||||
navButtonRefs.current[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
setFocusedIndex(navItems.length - 1)
|
||||
navButtonRefs.current[navItems.length - 1]?.focus()
|
||||
break
|
||||
}
|
||||
}, [handleNavClick])
|
||||
|
||||
// Update clock every minute
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(getCurrentTime())
|
||||
}, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Hash routing
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1) as ViewId
|
||||
if (navItems.some(item => item.id === hash)) {
|
||||
onViewChange(hash)
|
||||
}
|
||||
}
|
||||
|
||||
handleHashChange()
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||
}, [onViewChange])
|
||||
|
||||
// Alt+1-7 keyboard shortcuts and "/" for search
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.altKey && e.key >= '1' && e.key <= '7') {
|
||||
e.preventDefault()
|
||||
const index = parseInt(e.key) - 1
|
||||
if (navItems[index]) {
|
||||
const view = navItems[index].id
|
||||
onViewChange(view)
|
||||
window.location.hash = view
|
||||
}
|
||||
}
|
||||
if (e.key === '/' && !isSearchFocused && document.activeElement?.tagName !== 'INPUT') {
|
||||
e.preventDefault()
|
||||
const searchInput = document.getElementById('sidebar-search')
|
||||
searchInput?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onViewChange, isSearchFocused])
|
||||
|
||||
// Set focus-after-login ref to first nav button
|
||||
useEffect(() => {
|
||||
if (navButtonRefs.current[0]) {
|
||||
;(focusAfterLoginRef as React.MutableRefObject<HTMLButtonElement | null>).current = navButtonRefs.current[0]
|
||||
}
|
||||
}, [focusAfterLoginRef])
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
;(e.target as HTMLInputElement).blur()
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('')
|
||||
const searchInput = document.getElementById('sidebar-search')
|
||||
searchInput?.focus()
|
||||
}
|
||||
|
||||
// Fuzzy search with fuse.js
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchQuery.trim() || searchQuery.length < 2) return []
|
||||
const results = searchIndex.search(searchQuery)
|
||||
return results.slice(0, 10) // Limit to top 10 results
|
||||
}, [searchQuery, searchIndex])
|
||||
|
||||
// Group results by section for organized display
|
||||
const groupedResults = useMemo(() => {
|
||||
if (searchResults.length === 0) return new Map()
|
||||
return groupResultsBySection(searchResults)
|
||||
}, [searchResults])
|
||||
|
||||
const handleSearchResultClick = useCallback(
|
||||
(result: FuseResult<SearchResult>) => {
|
||||
// Navigate to the section
|
||||
onViewChange(result.item.section)
|
||||
window.location.hash = result.item.section
|
||||
|
||||
// Expand the matching item
|
||||
setExpandedItem(result.item.id)
|
||||
|
||||
// Clear search
|
||||
setSearchQuery('')
|
||||
},
|
||||
[onViewChange, setExpandedItem]
|
||||
)
|
||||
|
||||
// ── Tablet: 56px icon-only sidebar ──
|
||||
if (isTablet) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Clinical record navigation"
|
||||
className="hidden md:flex lg:hidden flex-col w-14 h-full bg-pmr-sidebar border-r border-[#334155] text-white"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-2 border-b border-white/10">
|
||||
<div className="font-ui font-medium text-[10px] text-white/50 text-center leading-tight">
|
||||
PMR
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 py-2 overflow-y-auto">
|
||||
<ul role="menu" aria-label="Record sections">
|
||||
{navItems.map((item, index) => (
|
||||
<li key={item.id} role="none" className="relative">
|
||||
{index === 1 && (
|
||||
<div className="mx-2 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
||||
)}
|
||||
<button
|
||||
ref={el => { navButtonRefs.current[index] = el }}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
||||
aria-current={activeView === item.id ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
onKeyDown={e => handleNavKeyDown(e, index)}
|
||||
onMouseEnter={() => setHoveredItem(item.id)}
|
||||
onMouseLeave={() => setHoveredItem(null)}
|
||||
className={`
|
||||
w-full flex items-center justify-center h-11
|
||||
transition-colors duration-150 relative
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
|
||||
${activeView === item.id
|
||||
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent'}
|
||||
`}
|
||||
>
|
||||
<span className={activeView === item.id ? 'text-white' : ''}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{/* Tooltip on hover */}
|
||||
{hoveredItem === item.id && (
|
||||
<div className="absolute left-full ml-2 px-2.5 py-1.5 bg-gray-900 text-white text-xs rounded whitespace-nowrap z-50 font-ui shadow-lg pointer-events-none">
|
||||
{item.label}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t border-white/10">
|
||||
<div className="font-ui text-[9px] text-[#64748B] text-center leading-relaxed">
|
||||
<div>A.C</div>
|
||||
<div>{currentTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Desktop: 220px full sidebar ──
|
||||
return (
|
||||
<nav
|
||||
aria-label="Clinical record navigation"
|
||||
className="hidden lg:flex flex-col w-[220px] h-full bg-pmr-sidebar border-r border-[#334155] text-white"
|
||||
>
|
||||
{/* Header branding */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="font-ui font-medium text-[13px] text-white/50 leading-tight">
|
||||
CareerRecord PMR
|
||||
</div>
|
||||
<div className="font-ui text-[11px] text-white/40 mt-0.5">v1.0.0</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/40 pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
id="sidebar-search"
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-label="Search record"
|
||||
aria-expanded={searchQuery.trim().length >= 2 && groupedResults.size > 0}
|
||||
aria-controls="search-results-listbox"
|
||||
aria-autocomplete="list"
|
||||
placeholder="Search record..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="w-full h-9 pl-8 pr-7 bg-white/[0.05] border border-white/10 rounded text-sm font-ui text-white placeholder-white/40 focus:outline-none focus:border-pmr-nhsblue focus:bg-white/[0.10] transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/* Search results dropdown — grouped by section */}
|
||||
{searchQuery.trim().length >= 2 && groupedResults.size > 0 && (
|
||||
<div
|
||||
id="search-results-listbox"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg"
|
||||
>
|
||||
{Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
|
||||
// Find section icon
|
||||
const navItem = navItems.find(item => item.label === sectionLabel)
|
||||
return (
|
||||
<div key={sectionLabel} role="group" aria-label={sectionLabel}>
|
||||
{/* Section header */}
|
||||
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
{navItem && <span className="text-white/40" aria-hidden="true">{navItem.icon}</span>}
|
||||
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
|
||||
{sectionLabel}
|
||||
</span>
|
||||
<span className="font-ui text-xs text-white/30">
|
||||
({results.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Results for this section */}
|
||||
{results.map((result: FuseResult<SearchResult>) => (
|
||||
<button
|
||||
key={result.item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => handleSearchResultClick(result)}
|
||||
className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0"
|
||||
>
|
||||
<div className="font-ui text-sm text-white leading-snug">
|
||||
{result.item.title}
|
||||
</div>
|
||||
<div className="font-ui text-xs text-white/50 mt-0.5 line-clamp-1">
|
||||
{result.item.highlight}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<div className="flex-1 py-2 overflow-y-auto">
|
||||
<ul role="menu" aria-label="Record sections">
|
||||
{navItems.map((item, index) => (
|
||||
<li key={item.id} role="none">
|
||||
{index === 1 && (
|
||||
<div className="mx-3 my-1 border-t border-white/10" role="separator" aria-hidden="true" />
|
||||
)}
|
||||
<button
|
||||
ref={el => { navButtonRefs.current[index] = el }}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={focusedIndex === null ? (index === 0 ? 0 : -1) : (focusedIndex === index ? 0 : -1)}
|
||||
aria-current={activeView === item.id ? 'page' : undefined}
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
onKeyDown={e => handleNavKeyDown(e, index)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 h-[44px] px-4
|
||||
font-ui text-[14px]
|
||||
transition-colors duration-150
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset
|
||||
${activeView === item.id
|
||||
? 'text-white bg-white/[0.12] border-l-[3px] border-pmr-nhsblue font-semibold'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/[0.08] border-l-[3px] border-transparent font-medium'}
|
||||
`}
|
||||
>
|
||||
<span className={`w-[18px] h-[18px] flex items-center justify-center ${activeView === item.id ? 'text-white' : 'text-white/60'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Footer: session info */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className="font-ui text-[11px] text-[#64748B] leading-relaxed">
|
||||
<div>Session: A.CHARLWOOD</div>
|
||||
<div>Logged in: {currentTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Phone, Mail, Linkedin, MapPin } from 'lucide-react'
|
||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
||||
import type { ContactItem } from '@/types'
|
||||
|
||||
const contactData: ContactItem[] = [
|
||||
{
|
||||
icon: 'phone',
|
||||
value: '07795553088',
|
||||
label: 'Phone',
|
||||
},
|
||||
{
|
||||
icon: 'mail',
|
||||
value: 'andy@charlwood.xyz',
|
||||
label: 'Email',
|
||||
href: 'mailto:andy@charlwood.xyz',
|
||||
},
|
||||
{
|
||||
icon: 'linkedin',
|
||||
value: 'linkedin.com/in/andrewcharlwood',
|
||||
label: 'LinkedIn',
|
||||
href: 'https://linkedin.com/in/andrewcharlwood',
|
||||
},
|
||||
{
|
||||
icon: 'mapPin',
|
||||
value: 'Norwich, UK',
|
||||
label: 'Location',
|
||||
},
|
||||
]
|
||||
|
||||
const iconMap = {
|
||||
phone: Phone,
|
||||
mail: Mail,
|
||||
linkedin: Linkedin,
|
||||
mapPin: MapPin,
|
||||
}
|
||||
|
||||
const ContactItemCard = ({
|
||||
item,
|
||||
delay,
|
||||
isVisible,
|
||||
}: {
|
||||
item: ContactItem
|
||||
delay: number
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
const Icon = iconMap[item.icon]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
||||
className="text-center"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-[rgba(0,137,123,0.08)] flex items-center justify-center mx-auto mb-2 text-teal">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="font-secondary text-[13px] text-heading break-words">
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
className="text-teal hover:text-[#00796B] transition-colors"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</div>
|
||||
<div className="font-secondary text-[10px] uppercase tracking-wider text-muted mt-0.5">
|
||||
{item.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Contact() {
|
||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="contact" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
||||
>
|
||||
Contact
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{contactData.map((item, index) => (
|
||||
<ContactItemCard
|
||||
key={item.label}
|
||||
item={item}
|
||||
delay={0.1 + index * 0.1}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { TopBar } from './TopBar'
|
||||
import { SubNav } from './SubNav'
|
||||
import Sidebar from './Sidebar'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import { DetailPanel } from './DetailPanel'
|
||||
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
|
||||
import { LatestResultsTile } from './tiles/LatestResultsTile'
|
||||
import { CoreSkillsTile } from './tiles/CoreSkillsTile'
|
||||
@@ -10,6 +12,8 @@ import { LastConsultationTile } from './tiles/LastConsultationTile'
|
||||
import { CareerActivityTile } from './tiles/CareerActivityTile'
|
||||
import { EducationTile } from './tiles/EducationTile'
|
||||
import { ProjectsTile } from './tiles/ProjectsTile'
|
||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { PaletteAction } from '@/lib/search'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -48,6 +52,8 @@ const contentVariants = {
|
||||
|
||||
export function DashboardLayout() {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
|
||||
const activeSection = useActiveSection()
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleSearchClick = () => {
|
||||
setCommandPaletteOpen(true)
|
||||
@@ -57,6 +63,11 @@ export function DashboardLayout() {
|
||||
setCommandPaletteOpen(false)
|
||||
}, [])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleSectionClick = useCallback((_sectionId: string) => {
|
||||
// SubNav handles scrolling internally
|
||||
}, [])
|
||||
|
||||
// Global Ctrl+K listener to open command palette
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -101,8 +112,12 @@ export function DashboardLayout() {
|
||||
window.open('/References/CV_v4.md', '_blank')
|
||||
break
|
||||
}
|
||||
case 'panel': {
|
||||
openPanel(action.panelContent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [openPanel])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -114,12 +129,15 @@ export function DashboardLayout() {
|
||||
<TopBar onSearchClick={handleSearchClick} />
|
||||
</motion.div>
|
||||
|
||||
{/* Layout below TopBar: Sidebar + Main */}
|
||||
{/* SubNav — sticky below TopBar */}
|
||||
<SubNav activeSection={activeSection} onSectionClick={handleSectionClick} />
|
||||
|
||||
{/* Layout below TopBar + SubNav: Sidebar + Main */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginTop: 'var(--topbar-height)',
|
||||
height: 'calc(100vh - var(--topbar-height))',
|
||||
marginTop: 'calc(var(--topbar-height) + var(--subnav-height))',
|
||||
height: 'calc(100vh - var(--topbar-height) - var(--subnav-height))',
|
||||
}}
|
||||
>
|
||||
{/* Sidebar — hidden on mobile/tablet, visible on desktop */}
|
||||
@@ -152,7 +170,10 @@ export function DashboardLayout() {
|
||||
|
||||
{/* LatestResultsTile — half width (left) */}
|
||||
<LatestResultsTile />
|
||||
{/* CoreSkillsTile — half width (right) */}
|
||||
{/* ProjectsTile — half width (right) */}
|
||||
<ProjectsTile />
|
||||
|
||||
{/* CoreSkillsTile — full width */}
|
||||
<CoreSkillsTile />
|
||||
|
||||
{/* LastConsultationTile — full width */}
|
||||
@@ -163,9 +184,6 @@ export function DashboardLayout() {
|
||||
|
||||
{/* EducationTile — full width */}
|
||||
<EducationTile />
|
||||
|
||||
{/* ProjectsTile — full width */}
|
||||
<ProjectsTile />
|
||||
</div>
|
||||
</motion.main>
|
||||
</div>
|
||||
@@ -176,6 +194,9 @@ export function DashboardLayout() {
|
||||
onClose={handlePaletteClose}
|
||||
onAction={handlePaletteAction}
|
||||
/>
|
||||
|
||||
{/* Detail panel */}
|
||||
<DetailPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap'
|
||||
import { DetailPanelContent } from '@/types/pmr'
|
||||
import type { CardHeaderProps } from './Card'
|
||||
import { KPIDetail } from './detail/KPIDetail'
|
||||
import { ConsultationDetail } from './detail/ConsultationDetail'
|
||||
import { SkillDetail } from './detail/SkillDetail'
|
||||
import { SkillsAllDetail } from './detail/SkillsAllDetail'
|
||||
import { EducationDetail } from './detail/EducationDetail'
|
||||
import { ProjectDetail } from './detail/ProjectDetail'
|
||||
|
||||
// Width mapping from content type
|
||||
const widthMap: Record<DetailPanelContent['type'], 'narrow' | 'wide'> = {
|
||||
kpi: 'narrow',
|
||||
skill: 'narrow',
|
||||
'skills-all': 'narrow',
|
||||
consultation: 'wide',
|
||||
project: 'wide',
|
||||
education: 'narrow',
|
||||
'career-role': 'wide',
|
||||
}
|
||||
|
||||
// Title mapping from content data
|
||||
function getPanelTitle(content: DetailPanelContent): string {
|
||||
switch (content.type) {
|
||||
case 'kpi':
|
||||
return content.kpi.label
|
||||
case 'skill':
|
||||
return content.skill.name
|
||||
case 'skills-all':
|
||||
return 'All Medications'
|
||||
case 'consultation':
|
||||
return content.consultation.role
|
||||
case 'project':
|
||||
return content.investigation.name
|
||||
case 'education':
|
||||
return content.document.title
|
||||
case 'career-role':
|
||||
return content.consultation.role
|
||||
}
|
||||
}
|
||||
|
||||
// Dot color mapping from content type
|
||||
function getDotColor(content: DetailPanelContent): CardHeaderProps['dotColor'] {
|
||||
switch (content.type) {
|
||||
case 'kpi':
|
||||
return 'teal'
|
||||
case 'skill':
|
||||
case 'skills-all':
|
||||
return 'amber'
|
||||
case 'consultation':
|
||||
case 'career-role':
|
||||
return 'teal'
|
||||
case 'project':
|
||||
return 'amber'
|
||||
case 'education':
|
||||
return 'purple'
|
||||
}
|
||||
}
|
||||
|
||||
// Dot color value map (from Card.tsx)
|
||||
const dotColorValueMap: Record<CardHeaderProps['dotColor'], string> = {
|
||||
teal: '#0D6E6E',
|
||||
amber: '#D97706',
|
||||
green: '#059669',
|
||||
alert: '#DC2626',
|
||||
purple: '#7C3AED',
|
||||
}
|
||||
|
||||
export function DetailPanel() {
|
||||
const { content, closePanel, isOpen } = useDetailPanel()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = 'detail-panel-title'
|
||||
|
||||
// Focus trap when open
|
||||
useFocusTrap(panelRef, isOpen)
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closePanel()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, closePanel])
|
||||
|
||||
if (!isOpen || !content) return null
|
||||
|
||||
const width = widthMap[content.type]
|
||||
const title = getPanelTitle(content)
|
||||
const dotColor = getDotColor(content)
|
||||
const dotColorValue = dotColorValueMap[dotColor]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'var(--backdrop-bg)',
|
||||
backdropFilter: 'blur(var(--backdrop-blur))',
|
||||
zIndex: 1000,
|
||||
animation: 'backdrop-fade-in 150ms ease-out',
|
||||
}}
|
||||
onClick={closePanel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
className="detail-panel"
|
||||
data-width={width}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'var(--surface)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
zIndex: 1001,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'panel-slide-in 250ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '20px 24px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: dotColorValue,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2
|
||||
id={titleId}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closePanel}
|
||||
aria-label="Close panel"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-secondary)',
|
||||
transition: 'background-color 150ms, color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-light)'
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
e.currentTarget.style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body (scrollable) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Render content based on type */}
|
||||
{content.type === 'kpi' && <KPIDetail kpi={content.kpi} />}
|
||||
{(content.type === 'consultation' || content.type === 'career-role') && (
|
||||
<ConsultationDetail consultation={content.consultation} />
|
||||
)}
|
||||
|
||||
{content.type === 'skill' && <SkillDetail skill={content.skill} />}
|
||||
{content.type === 'skills-all' && <SkillsAllDetail category={content.category} />}
|
||||
{content.type === 'education' && <EducationDetail document={content.document} />}
|
||||
{content.type === 'project' && <ProjectDetail investigation={content.investigation} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
||||
import type { Education as EducationType } from '@/types'
|
||||
|
||||
const educationData: EducationType[] = [
|
||||
{
|
||||
degree: 'MPharm (Hons) Pharmacy',
|
||||
institution: 'University of East Anglia',
|
||||
period: '2011 — 2015',
|
||||
detail: 'Upper Second-Class Honours (2:1)',
|
||||
},
|
||||
{
|
||||
degree: 'Mary Seacole Leadership Programme',
|
||||
institution: 'NHS Leadership Academy',
|
||||
period: '2018',
|
||||
detail: 'National healthcare leadership development programme.',
|
||||
},
|
||||
]
|
||||
|
||||
const EducationCard = ({
|
||||
education,
|
||||
delay,
|
||||
isVisible,
|
||||
}: {
|
||||
education: EducationType
|
||||
delay: number
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
||||
className="relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-shadow hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r from-teal to-coral" />
|
||||
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
|
||||
{education.degree}
|
||||
</h3>
|
||||
<p className="text-sm text-teal mt-0.5">{education.institution}</p>
|
||||
<p className="text-[13px] text-muted mt-0.5">{education.period}</p>
|
||||
<p className="text-sm text-text mt-1.5 leading-relaxed">
|
||||
{education.detail}
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Education() {
|
||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="education" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
||||
>
|
||||
Education
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{educationData.map((education, index) => (
|
||||
<EducationCard
|
||||
key={education.degree}
|
||||
education={education}
|
||||
delay={0.1 + index * 0.1}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isVisible ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="text-[13px] text-muted text-center mt-5"
|
||||
>
|
||||
A-Levels: Mathematics (A*), Chemistry (B), Politics (C)
|
||||
</motion.p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
||||
import type { Experience as ExperienceType } from '@/types'
|
||||
|
||||
const experiences: ExperienceType[] = [
|
||||
{
|
||||
role: 'Interim Head of Population Health & Data Analysis',
|
||||
org: 'NHS Norfolk & Waveney ICB',
|
||||
date: 'May 2025 — Nov 2025',
|
||||
bullets: [
|
||||
'Led team through organisational transition, maintaining delivery of £14.6M efficiency programme',
|
||||
'Directed strategic priorities for population health analytics across Norfolk & Waveney (population ~1M)',
|
||||
'Managed stakeholder relationships with system leaders, provider trusts, and primary care networks',
|
||||
],
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
role: 'Deputy Head of Population Health & Data Analysis',
|
||||
org: 'NHS Norfolk & Waveney ICB',
|
||||
date: 'Jul 2024 — Present',
|
||||
bullets: [
|
||||
'Deputised for Head of department across all operational and strategic functions',
|
||||
'Oversaw £220M medicines budget and led programme of cost improvement initiatives',
|
||||
'Developed Python-based switching algorithm processing 14,000 patients, delivering £2.6M savings',
|
||||
'Built Blueteq automation system reducing processing time by 70%, saving 200+ hours annually',
|
||||
'Created PharMetrics dashboard platform for real-time medicines expenditure tracking',
|
||||
],
|
||||
isCurrent: true,
|
||||
},
|
||||
{
|
||||
role: 'High-Cost Drugs & Interface Pharmacist',
|
||||
org: 'NHS Norfolk & Waveney ICB',
|
||||
date: 'May 2022 — Jul 2024',
|
||||
bullets: [
|
||||
'Managed high-cost drugs budget across acute and community settings',
|
||||
'Led NICE Technology Appraisal implementation and horizon scanning',
|
||||
'Developed health economic models for biosimilar switching programmes',
|
||||
'Built data pipelines for automated reporting of medicines expenditure',
|
||||
],
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
role: 'Pharmacy Manager',
|
||||
org: 'Tesco Pharmacy',
|
||||
date: 'Nov 2017 — May 2022',
|
||||
bullets: [
|
||||
'Managed community pharmacy delivering 3,000+ items monthly',
|
||||
'Pioneered asthma screening service generating £1M+ national revenue',
|
||||
'Led team of 6 through COVID-19 pandemic service delivery',
|
||||
'Completed Mary Seacole NHS Leadership Programme (2018)',
|
||||
],
|
||||
isCurrent: false,
|
||||
},
|
||||
{
|
||||
role: 'Duty Pharmacy Manager',
|
||||
org: 'Tesco Pharmacy',
|
||||
date: 'Aug 2016 — Nov 2017',
|
||||
bullets: [
|
||||
'Supported pharmacy manager in daily operations and clinical services',
|
||||
'Delivered Medicines Use Reviews and New Medicine Service consultations',
|
||||
'Maintained controlled drug compliance and clinical governance standards',
|
||||
],
|
||||
isCurrent: false,
|
||||
},
|
||||
]
|
||||
|
||||
const ECGDecoration = () => (
|
||||
<svg
|
||||
className="shrink-0 w-[120px] xs:w-[200px] h-[30px]"
|
||||
viewBox="0 0 200 30"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M 0 15 L 40 15 L 50 15 C 53 15 55 12 58 12 C 61 12 63 15 66 15 L 76 15 L 80 20 L 86 2 L 92 22 L 96 15 L 106 15 C 109 15 111 11 114 11 C 117 11 120 15 123 15 L 200 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-teal opacity-30"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TimelineEntry = ({
|
||||
experience,
|
||||
index,
|
||||
isVisible,
|
||||
}: {
|
||||
experience: ExperienceType
|
||||
index: number
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="relative pl-0 md:pl-[calc(20%+32px)] mb-8 last:mb-0"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<div
|
||||
className={`absolute left-[20%] top-2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-teal bg-white z-10 hidden md:block ${
|
||||
experience.isCurrent ? 'bg-teal' : ''
|
||||
}`}
|
||||
/>
|
||||
<motion.div
|
||||
className="bg-white rounded-2xl p-4 xs:p-6 shadow-sm border-l-[3px] border-transparent hover:shadow-md hover:scale-[1.01] hover:border-l-teal/30 transition-all duration-300"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<h3 className="font-primary text-[17px] font-semibold text-heading leading-tight">
|
||||
{experience.role}
|
||||
</h3>
|
||||
<p className="font-primary text-sm text-teal mt-0.5">{experience.org}</p>
|
||||
<span className="inline-block px-2.5 py-0.5 mt-1.5 mb-3 bg-teal/8 rounded-full font-secondary text-xs text-teal font-medium">
|
||||
{experience.date}
|
||||
</span>
|
||||
<ul className="space-y-1">
|
||||
{experience.bullets.map((bullet, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="relative pl-4 text-sm text-text leading-relaxed before:content-[''] before:absolute before:left-0 before:top-[10px] before:w-[5px] before:h-[5px] before:rounded-full before:bg-teal"
|
||||
>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Experience() {
|
||||
const [sectionRef, isVisible] = useScrollReveal<HTMLDivElement>({ threshold: 0.1 })
|
||||
|
||||
return (
|
||||
<div
|
||||
id="experience"
|
||||
ref={sectionRef}
|
||||
className="py-12 xs:py-16 md:py-20 opacity-0 translate-y-6 transition-all duration-600 ease-out data-[visible=true]:opacity-100 data-[visible=true]:translate-y-0"
|
||||
data-visible={isVisible}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
<h2 className="font-primary text-2xl font-bold text-heading">Experience</h2>
|
||||
<ECGDecoration />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute left-[20%] top-0 bottom-0 w-0.5 bg-teal/20 hidden md:block" />
|
||||
|
||||
<div className="space-y-0">
|
||||
{experiences.map((exp, i) => (
|
||||
<TimelineEntry
|
||||
key={exp.role}
|
||||
experience={exp}
|
||||
index={i}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useActiveSection } from '@/hooks/useActiveSection'
|
||||
|
||||
interface NavLink {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{ id: 'about', label: 'About' },
|
||||
{ id: 'skills', label: 'Skills' },
|
||||
{ id: 'experience', label: 'Experience' },
|
||||
{ id: 'education', label: 'Education' },
|
||||
{ id: 'projects', label: 'Projects' },
|
||||
{ id: 'contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
export function FloatingNav() {
|
||||
const activeSection = useActiveSection()
|
||||
|
||||
const scrollToSection = useCallback((sectionId: string) => {
|
||||
const element = document.getElementById(sectionId)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
className="fixed top-4 left-1/2 -translate-x-1/2 z-[100] max-w-[600px] w-[calc(100%-32px)] md:w-auto bg-white rounded-full py-2 px-4 md:px-6 shadow-md flex items-center gap-1 border border-border overflow-x-auto scrollbar-hide"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
>
|
||||
{navLinks.map((link) => {
|
||||
const isActive = activeSection === link.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => scrollToSection(link.id)}
|
||||
className={`
|
||||
relative font-secondary text-[11px] xs:text-[13px] font-medium py-1.5 px-2.5 xs:px-3.5 rounded-full
|
||||
transition-colors duration-300 ease-out whitespace-nowrap
|
||||
${isActive
|
||||
? 'text-teal font-semibold'
|
||||
: 'text-muted hover:text-teal hover:bg-teal-light'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{link.label}
|
||||
{isActive && (
|
||||
<motion.span
|
||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-teal"
|
||||
layoutId="navIndicator"
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.nav>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<motion.footer
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
className="text-center pt-8 xs:pt-12 pb-6 xs:pb-8 border-t border-slate-200"
|
||||
>
|
||||
<svg
|
||||
className="block mx-auto mb-3"
|
||||
width="120"
|
||||
height="20"
|
||||
viewBox="0 0 120 20"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M 0 10 L 35 10 L 40 10 C 42 10 43 7 45 7 C 47 7 48 10 50 10 L 54 10 L 56 13 L 60 2 L 64 15 L 66 10 L 70 10 C 72 10 73 7 75 7 C 77 7 78 10 80 10 L 120 10"
|
||||
stroke="#00897B"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.3"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<p className="font-secondary text-xs text-muted">
|
||||
Andy Charlwood — MPharm, GPhC Registered Pharmacist
|
||||
</p>
|
||||
</motion.footer>
|
||||
)
|
||||
}
|
||||
|
||||
export { Footer }
|
||||
@@ -1,85 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface VitalCardProps {
|
||||
value: string
|
||||
label: string
|
||||
valueSize?: 'default' | 'small' | 'medium'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function VitalCard({ value, label, valueSize = 'default', delay = 0 }: VitalCardProps) {
|
||||
const sizeClasses = {
|
||||
default: 'text-[28px]',
|
||||
small: 'text-base',
|
||||
medium: 'text-lg'
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
||||
className="bg-card-bg rounded-2xl px-6 py-5 shadow-sm border-t-[3px] border-teal min-w-[160px] text-center transition-all duration-300 hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div className={`font-primary font-bold text-heading leading-tight ${sizeClasses[valueSize]}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="font-secondary text-[11px] uppercase tracking-wide text-muted mt-1">
|
||||
{label}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section
|
||||
id="about"
|
||||
className="min-h-screen flex flex-col justify-center items-center text-center py-12 xs:py-16 md:py-20"
|
||||
>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="font-primary font-bold text-heading leading-tight"
|
||||
style={{ fontSize: 'clamp(28px, 5vw, 52px)' }}
|
||||
>
|
||||
Andy Charlwood
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.15 }}
|
||||
className="text-muted text-base mt-2"
|
||||
>
|
||||
Deputy Head of Population Health & Data Analysis
|
||||
</motion.p>
|
||||
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="inline-block mt-1 px-4 py-1 border border-teal rounded-full text-xs text-teal font-medium"
|
||||
>
|
||||
Norwich, UK
|
||||
</motion.span>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 max-w-[560px] text-text text-[15px] leading-[1.8]"
|
||||
>
|
||||
GPhC Registered Pharmacist specialising in medicines optimisation, population health analytics, and NHS efficiency programmes. Bridging clinical pharmacy with data science to drive meaningful improvements in patient outcomes.
|
||||
</motion.p>
|
||||
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:flex gap-4 mt-10 justify-center md:flex-wrap">
|
||||
<VitalCard value="10+" label="Years Experience" delay={0.4} />
|
||||
<VitalCard value="Python/SQL/BI" label="Analytics Stack" valueSize="small" delay={0.5} />
|
||||
<VitalCard value="Pop. Health" label="Focus Area" valueSize="medium" delay={0.6} />
|
||||
<VitalCard value="NHS N&W" label="System" valueSize="medium" delay={0.7} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+260
-176
@@ -14,11 +14,13 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
const [activeField, setActiveField] = useState<'username' | 'password' | 'done' | null>('username')
|
||||
const [buttonPressed, setButtonPressed] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [typingComplete, setTypingComplete] = useState(false)
|
||||
const [buttonHovered, setButtonHovered] = useState(false)
|
||||
const [connectionState, setConnectionState] = useState<'connecting' | 'connected'>('connecting')
|
||||
const { requestFocusAfterLogin } = useAccessibility()
|
||||
|
||||
const fullUsername = 'A.CHARLWOOD'
|
||||
const fullUsername = 'a.recruiter'
|
||||
const passwordLength = 8
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
@@ -38,17 +40,22 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const canLogin = typingComplete && connectionState === 'connected'
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
if (!typingComplete || isExiting) return
|
||||
if (!canLogin || isExiting || isLoading) return
|
||||
setButtonPressed(true)
|
||||
addTimeout(() => {
|
||||
setIsExiting(true)
|
||||
setIsLoading(true)
|
||||
addTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, prefersReducedMotion ? 0 : 200)
|
||||
setIsExiting(true)
|
||||
addTimeout(() => {
|
||||
requestFocusAfterLogin()
|
||||
onComplete()
|
||||
}, prefersReducedMotion ? 0 : 200)
|
||||
}, prefersReducedMotion ? 0 : 600)
|
||||
}, 100)
|
||||
}, [typingComplete, isExiting, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||
}, [canLogin, isExiting, isLoading, onComplete, requestFocusAfterLogin, prefersReducedMotion, addTimeout])
|
||||
|
||||
const startLoginSequence = useCallback(() => {
|
||||
if (prefersReducedMotion) {
|
||||
@@ -93,12 +100,12 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
}, 80)
|
||||
}, [prefersReducedMotion, addTimeout])
|
||||
|
||||
// Focus the login button when typing completes for keyboard accessibility
|
||||
// Focus the login button when login becomes available for keyboard accessibility
|
||||
useEffect(() => {
|
||||
if (typingComplete && loginButtonRef.current) {
|
||||
if (canLogin && loginButtonRef.current) {
|
||||
loginButtonRef.current.focus()
|
||||
}
|
||||
}, [typingComplete])
|
||||
}, [canLogin])
|
||||
|
||||
useEffect(() => {
|
||||
// Cursor blink: 530ms interval
|
||||
@@ -106,6 +113,11 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
setShowCursor(prev => !prev)
|
||||
}, 530)
|
||||
|
||||
// Connection status: transitions to connected after ~2000ms
|
||||
const connectionTimeout = addTimeout(() => {
|
||||
setConnectionState('connected')
|
||||
}, 2000)
|
||||
|
||||
// Delay start slightly for card entrance animation
|
||||
const startTimeout = addTimeout(() => {
|
||||
startLoginSequence()
|
||||
@@ -119,20 +131,21 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
if (usernameIntervalRef.current) clearInterval(usernameIntervalRef.current)
|
||||
if (passwordIntervalRef.current) clearInterval(passwordIntervalRef.current)
|
||||
clearTimeout(startTimeout)
|
||||
clearTimeout(connectionTimeout)
|
||||
pendingTimeouts.forEach(id => clearTimeout(id))
|
||||
}
|
||||
}, [startLoginSequence, addTimeout])
|
||||
|
||||
const buttonBg = buttonPressed
|
||||
? '#004494'
|
||||
: buttonHovered && typingComplete
|
||||
? '#004D9F'
|
||||
: '#005EB8'
|
||||
? '#085858'
|
||||
: buttonHovered && canLogin
|
||||
? '#0A8080'
|
||||
: '#0D6E6E'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: '#1E293B' }}
|
||||
style={{ backgroundColor: '#1A2B2A' }}
|
||||
role="dialog"
|
||||
aria-label="Clinical system login"
|
||||
aria-modal="true"
|
||||
@@ -150,182 +163,253 @@ export function LoginScreen({ onComplete }: LoginScreenProps) {
|
||||
animate={isExiting ? { scale: 1.03, opacity: 0 } : { scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
{/* Branding Header */}
|
||||
<div
|
||||
className="flex flex-col items-center"
|
||||
style={{ marginBottom: '28px' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(0, 94, 184, 0.07)',
|
||||
marginBottom: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 0',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<Shield
|
||||
size={26}
|
||||
style={{ color: '#005EB8' }}
|
||||
strokeWidth={2.5}
|
||||
<div
|
||||
className="login-spinner"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '3px solid #E5E7EB',
|
||||
borderTopColor: '#0D6E6E',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
role="status"
|
||||
aria-label="Loading clinical records"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#64748B',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
CareerRecord PMR
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: '#94A3B8',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
Clinical Information System
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
marginBottom: '6px',
|
||||
color: 'var(--text-secondary, #5B7A78)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
Loading clinical records...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Branding Header */}
|
||||
<div
|
||||
className="flex flex-col items-center"
|
||||
style={{ marginBottom: '28px' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'rgba(13, 110, 110, 0.08)',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<Shield
|
||||
size={26}
|
||||
style={{ color: '#0D6E6E' }}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: '#64748B',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
CareerRecord PMR
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '11px',
|
||||
fontWeight: 400,
|
||||
color: '#94A3B8',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
Clinical Information System
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'username' ? '1px solid #0D6E6E' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span>{username}</span>
|
||||
{activeField === 'username' && (
|
||||
<span
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#0D6E6E' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'password' ? '1px solid #0D6E6E' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
letterSpacing: '0.15em',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||
{activeField === 'password' && (
|
||||
<span
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#0D6E6E' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log In Button — user clicks to proceed */}
|
||||
<button
|
||||
ref={loginButtonRef}
|
||||
onClick={handleLogin}
|
||||
disabled={!canLogin}
|
||||
onMouseEnter={() => setButtonHovered(true)}
|
||||
onMouseLeave={() => setButtonHovered(false)}
|
||||
className="focus-visible:ring-2 focus-visible:ring-[#0D6E6E]/40 focus-visible:ring-offset-2 focus:outline-none"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: buttonBg,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: canLogin ? 'pointer' : 'default',
|
||||
opacity: canLogin ? 1 : 0.6,
|
||||
transition: 'background-color 150ms, opacity 300ms',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
|
||||
{/* Connection Status Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: connectionState === 'connected' ? '#059669' : '#DC2626',
|
||||
transition: prefersReducedMotion ? 'none' : 'background-color 300ms ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-geist-mono, 'Geist Mono', monospace)",
|
||||
fontSize: '10px',
|
||||
color: connectionState === 'connected' ? '#059669' : '#8DA8A5',
|
||||
transition: prefersReducedMotion ? 'none' : 'color 300ms ease',
|
||||
}}
|
||||
>
|
||||
{connectionState === 'connected'
|
||||
? 'Secure connection established'
|
||||
: 'Awaiting secure connection...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: activeField === 'username' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'username' ? '1px solid #005EB8' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
marginTop: '22px',
|
||||
paddingTop: '18px',
|
||||
borderTop: '1px solid #E5E7EB',
|
||||
}}
|
||||
>
|
||||
<span>{username}</span>
|
||||
{activeField === 'username' && (
|
||||
<span
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '11px',
|
||||
color: '#94A3B8',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Secure clinical system login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#64748B',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '9px 11px',
|
||||
fontFamily: "'Geist Mono', 'Fira Code', monospace",
|
||||
fontSize: '13px',
|
||||
backgroundColor: activeField === 'password' ? '#FFFFFF' : '#FAFAFA',
|
||||
border: activeField === 'password' ? '1px solid #005EB8' : '1px solid #E5E7EB',
|
||||
borderRadius: '4px',
|
||||
color: '#111827',
|
||||
letterSpacing: '0.15em',
|
||||
minHeight: '38px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'background-color 150ms ease-out, border-color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<span>{'\u2022'.repeat(passwordDots)}</span>
|
||||
{activeField === 'password' && (
|
||||
<span
|
||||
style={{ opacity: showCursor ? 1 : 0, color: '#005EB8' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log In Button — user clicks to proceed */}
|
||||
<button
|
||||
ref={loginButtonRef}
|
||||
onClick={handleLogin}
|
||||
disabled={!typingComplete}
|
||||
onMouseEnter={() => setButtonHovered(true)}
|
||||
onMouseLeave={() => setButtonHovered(false)}
|
||||
className="focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-offset-2 focus:outline-none"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#FFFFFF',
|
||||
backgroundColor: buttonBg,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: typingComplete ? 'pointer' : 'default',
|
||||
opacity: typingComplete ? 1 : 0.6,
|
||||
transition: 'background-color 150ms, opacity 300ms',
|
||||
}}
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '22px',
|
||||
paddingTop: '18px',
|
||||
borderTop: '1px solid #E5E7EB',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: '11px',
|
||||
color: '#94A3B8',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Secure clinical system login
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { ClipboardList, FileText, Pill, AlertTriangle, FlaskConical, FolderOpen, Send } from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
|
||||
interface NavItem {
|
||||
id: ViewId
|
||||
label: string
|
||||
shortLabel: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'summary', label: 'Summary', shortLabel: 'Summary', icon: <ClipboardList size={20} /> },
|
||||
{ id: 'consultations', label: 'Experience', shortLabel: 'Exp', icon: <FileText size={20} /> },
|
||||
{ id: 'medications', label: 'Skills', shortLabel: 'Skills', icon: <Pill size={20} /> },
|
||||
{ id: 'problems', label: 'Achievements', shortLabel: 'Achieve', icon: <AlertTriangle size={20} /> },
|
||||
{ id: 'investigations', label: 'Projects', shortLabel: 'Projects', icon: <FlaskConical size={20} /> },
|
||||
{ id: 'documents', label: 'Education', shortLabel: 'Edu', icon: <FolderOpen size={20} /> },
|
||||
{ id: 'referrals', label: 'Contact', shortLabel: 'Contact', icon: <Send size={20} /> },
|
||||
]
|
||||
|
||||
interface MobileBottomNavProps {
|
||||
activeView: ViewId
|
||||
onViewChange: (view: ViewId) => void
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ activeView, onViewChange }: MobileBottomNavProps) {
|
||||
const handleNavClick = (view: ViewId) => {
|
||||
onViewChange(view)
|
||||
window.location.hash = view
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 bg-pmr-sidebar border-t border-white/10"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
role="navigation"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<ul className="flex items-center justify-around h-14">
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeView === item.id
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
className={`
|
||||
flex flex-col items-center justify-center
|
||||
w-12 h-14 rounded-lg
|
||||
transition-colors duration-100
|
||||
${isActive
|
||||
? 'text-pmr-nhsblue'
|
||||
: 'text-white/60 hover:text-white/90'}
|
||||
`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
aria-label={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-[10px] mt-0.5 font-ui font-medium">
|
||||
{item.shortLabel}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { motion, Variants } from 'framer-motion'
|
||||
import { Search, X, ArrowLeft } from 'lucide-react'
|
||||
import type { ViewId } from '../types/pmr'
|
||||
import { ClinicalSidebar } from './ClinicalSidebar'
|
||||
import { PatientBanner } from './PatientBanner'
|
||||
import { MobileBottomNav } from './MobileBottomNav'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { SummaryView } from './views/SummaryView'
|
||||
import { ConsultationsView } from './views/ConsultationsView'
|
||||
import { MedicationsView } from './views/MedicationsView'
|
||||
import { ProblemsView } from './views/ProblemsView'
|
||||
import { InvestigationsView } from './views/InvestigationsView'
|
||||
import { DocumentsView } from './views/DocumentsView'
|
||||
import { ReferralsView } from './views/ReferralsView'
|
||||
import { useAccessibility } from '../contexts/AccessibilityContext'
|
||||
import { useBreakpoint } from '../hooks/useBreakpoint'
|
||||
import { useScrollCondensation } from '../hooks/useScrollCondensation'
|
||||
|
||||
interface PMRInterfaceProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function PMRContent({ children }: PMRInterfaceProps) {
|
||||
const [activeView, setActiveView] = useState<ViewId>(() => {
|
||||
const hash = window.location.hash.slice(1) as ViewId
|
||||
const validViews: ViewId[] = [
|
||||
'summary',
|
||||
'consultations',
|
||||
'medications',
|
||||
'problems',
|
||||
'investigations',
|
||||
'documents',
|
||||
'referrals',
|
||||
]
|
||||
return validViews.includes(hash) ? hash : 'summary'
|
||||
})
|
||||
|
||||
const [mobileSearchQuery, setMobileSearchQuery] = useState('')
|
||||
|
||||
const viewHeadingRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLElement | null>(null)
|
||||
const scrollContainerCallbackRef = useCallback((node: HTMLElement | null) => {
|
||||
setScrollContainer(node)
|
||||
}, [])
|
||||
const { requestFocusAfterViewChange, expandedItemId, setExpandedItem } = useAccessibility()
|
||||
const { isMobile, isTablet } = useBreakpoint()
|
||||
const { isCondensed } = useScrollCondensation({ threshold: 100, scrollContainer })
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
const bannerVariants = useMemo<Variants>(() => ({
|
||||
hidden: prefersReducedMotion ? {} : { y: -80, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
}), [prefersReducedMotion])
|
||||
|
||||
const sidebarVariants = useMemo<Variants>(() => ({
|
||||
hidden: prefersReducedMotion ? {} : { x: -220, opacity: 0 },
|
||||
visible: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.25, ease: 'easeOut', delay: 0.05 }
|
||||
}
|
||||
}), [prefersReducedMotion])
|
||||
|
||||
const contentVariants = useMemo<Variants>(() => ({
|
||||
hidden: prefersReducedMotion ? {} : { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.3, delay: 0.15 }
|
||||
}
|
||||
}), [prefersReducedMotion])
|
||||
|
||||
const mobileNavVariants = useMemo<Variants>(() => ({
|
||||
hidden: prefersReducedMotion ? {} : { y: 56, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
}), [prefersReducedMotion])
|
||||
|
||||
useEffect(() => {
|
||||
requestFocusAfterViewChange()
|
||||
if (viewHeadingRef.current) {
|
||||
viewHeadingRef.current.focus()
|
||||
}
|
||||
}, [activeView, requestFocusAfterViewChange])
|
||||
|
||||
const handleViewChange = (view: ViewId) => {
|
||||
setActiveView(view)
|
||||
if (expandedItemId) {
|
||||
setExpandedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavigate = (view: ViewId) => {
|
||||
setActiveView(view)
|
||||
window.location.hash = view
|
||||
if (expandedItemId) {
|
||||
setExpandedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToSummary = () => {
|
||||
handleViewChange('summary')
|
||||
window.location.hash = 'summary'
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
switch (activeView) {
|
||||
case 'summary':
|
||||
return <SummaryView onNavigate={handleNavigate} />
|
||||
case 'consultations':
|
||||
return <ConsultationsView />
|
||||
case 'medications':
|
||||
return <MedicationsView />
|
||||
case 'problems':
|
||||
return <ProblemsView onNavigate={handleNavigate} />
|
||||
case 'investigations':
|
||||
return <InvestigationsView />
|
||||
case 'documents':
|
||||
return <DocumentsView />
|
||||
case 'referrals':
|
||||
return <ReferralsView />
|
||||
default:
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded p-6 shadow-pmr">
|
||||
<h1 className="font-ui font-semibold text-lg text-gray-900 capitalize">
|
||||
{activeView} View
|
||||
</h1>
|
||||
<p className="font-ui text-sm text-gray-500 mt-2">
|
||||
Content for {activeView} will be implemented in a separate task.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const viewLabels: Record<ViewId, string> = {
|
||||
summary: 'Summary',
|
||||
consultations: 'Experience',
|
||||
medications: 'Skills',
|
||||
problems: 'Achievements',
|
||||
investigations: 'Projects',
|
||||
documents: 'Education',
|
||||
referrals: 'Contact',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-screen overflow-hidden bg-pmr-content"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Fixed sidebar */}
|
||||
{!isMobile && (
|
||||
<motion.div variants={sidebarVariants} className="flex-shrink-0">
|
||||
<ClinicalSidebar
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main content column: banner (fixed) + scrollable content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<motion.div variants={bannerVariants} className="flex-shrink-0">
|
||||
<PatientBanner isMobile={isMobile} isTablet={isTablet} isCondensed={isCondensed} />
|
||||
</motion.div>
|
||||
|
||||
<motion.main
|
||||
ref={scrollContainerCallbackRef}
|
||||
variants={contentVariants}
|
||||
aria-label={`${viewLabels[activeView]} view`}
|
||||
className={`
|
||||
flex-1 overflow-y-auto p-4 md:p-6
|
||||
${isMobile ? 'pb-20' : ''}
|
||||
`}
|
||||
>
|
||||
{isMobile && (
|
||||
<MobileSearchBar
|
||||
query={mobileSearchQuery}
|
||||
onChange={setMobileSearchQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="outline-none"
|
||||
aria-label={viewLabels[activeView]}
|
||||
>
|
||||
<h1 className="sr-only">{viewLabels[activeView]}</h1>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb (desktop/tablet only) */}
|
||||
{!isMobile && (
|
||||
<Breadcrumb
|
||||
currentView={activeView}
|
||||
expandedItem={
|
||||
expandedItemId
|
||||
? { name: expandedItemId, type: activeView }
|
||||
: undefined
|
||||
}
|
||||
onNavigateToView={handleNavigate}
|
||||
onCollapseItem={() => setExpandedItem(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile back button (mobile only) */}
|
||||
{isMobile && activeView !== 'summary' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToSummary}
|
||||
className="flex items-center gap-1 text-pmr-nhsblue text-sm font-ui font-medium mb-4 hover:underline"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to Summary
|
||||
</button>
|
||||
)}
|
||||
|
||||
{children || renderView()}
|
||||
</motion.main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<motion.div variants={mobileNavVariants}>
|
||||
<MobileBottomNav
|
||||
activeView={activeView}
|
||||
onViewChange={handleViewChange}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MobileSearchBarProps {
|
||||
query: string
|
||||
onChange: (query: string) => void
|
||||
}
|
||||
|
||||
function MobileSearchBar({ query, onChange }: MobileSearchBarProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search record"
|
||||
placeholder="Search record..."
|
||||
value={query}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full h-10 pl-10 pr-10 bg-white border border-gray-200 rounded text-sm font-ui text-gray-900 placeholder-gray-400 focus:outline-none focus:border-pmr-nhsblue focus:ring-1 focus:ring-pmr-nhsblue/20 transition-colors"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PMRInterface(props: PMRInterfaceProps) {
|
||||
return <PMRContent {...props} />
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
import { Download, Mail, Linkedin, MoreHorizontal } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { patient } from '@/data/patient'
|
||||
|
||||
interface PatientBannerProps {
|
||||
isMobile?: boolean
|
||||
isTablet?: boolean
|
||||
isCondensed?: boolean
|
||||
}
|
||||
|
||||
export function PatientBanner({ isMobile = false, isTablet = false, isCondensed = false }: PatientBannerProps) {
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileBanner />
|
||||
}
|
||||
|
||||
const shouldCondense = isTablet || isCondensed
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`
|
||||
w-full z-40
|
||||
bg-pmr-banner border-b border-slate-600
|
||||
shadow-pmr-banner
|
||||
transition-all duration-200 ease-out
|
||||
${shouldCondense ? 'h-12' : 'h-20'}
|
||||
`}
|
||||
role="banner"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{shouldCondense ? (
|
||||
<motion.div
|
||||
key="condensed"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="h-full"
|
||||
>
|
||||
<CondensedBanner />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="full"
|
||||
initial={prefersReducedMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={prefersReducedMotion ? undefined : { opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="h-full"
|
||||
>
|
||||
<FullBanner />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileBanner() {
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleClickOutside = useCallback((e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setShowOverflow(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showOverflow) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showOverflow, handleClickOutside])
|
||||
|
||||
return (
|
||||
<header
|
||||
className="w-full z-40 h-12 bg-pmr-banner border-b border-slate-600 shadow-pmr-banner"
|
||||
role="banner"
|
||||
>
|
||||
<div className="h-full px-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h1 className="font-ui font-semibold text-white text-sm tracking-tight truncate">
|
||||
CHARLWOOD, A (Mr)
|
||||
</h1>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span className="font-geist text-xs text-slate-300">
|
||||
{patient.nhsNumber}
|
||||
</span>
|
||||
<StatusDot status={patient.status} />
|
||||
</div>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="p-2 text-white/70 hover:text-white transition-colors"
|
||||
aria-label="Actions menu"
|
||||
aria-expanded={showOverflow}
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showOverflow && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-1 w-44 bg-white border border-pmr-border rounded shadow-pmr z-50 py-1"
|
||||
>
|
||||
<a
|
||||
href="/cv.pdf"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
>
|
||||
<Download size={14} />
|
||||
Download CV
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${patient.email}`}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
>
|
||||
<Mail size={14} />
|
||||
Email
|
||||
</a>
|
||||
<a
|
||||
href={`https://${patient.linkedin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-ui text-pmr-text-primary hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowOverflow(false)}
|
||||
>
|
||||
<Linkedin size={14} />
|
||||
LinkedIn
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function FullBanner() {
|
||||
return (
|
||||
<div className="h-full px-4 lg:px-6 flex flex-col justify-center">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Row 1: Name, status, badge */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="font-ui font-semibold text-white text-lg tracking-tight">
|
||||
{patient.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot status={patient.status} />
|
||||
<span className="text-slate-400 text-sm font-ui">{patient.status}</span>
|
||||
</div>
|
||||
{patient.badge && <StatusBadge badge={patient.badge} />}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Demographics with pipe separators */}
|
||||
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300 font-ui">
|
||||
<span>
|
||||
<span className="text-slate-500">DOB:</span>{' '}
|
||||
<span className="font-geist">{patient.dob}</span>
|
||||
</span>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-slate-500">NHS No:</span>{' '}
|
||||
<NHSNumberWithTooltip />
|
||||
</span>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span>{patient.address}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Contact details */}
|
||||
<div className="flex items-center gap-4 mt-1 flex-wrap text-sm text-slate-300 font-ui">
|
||||
<a
|
||||
href={`tel:${patient.phone}`}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
{patient.phone}
|
||||
</a>
|
||||
<span className="text-slate-500">|</span>
|
||||
<a
|
||||
href={`mailto:${patient.email}`}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
{patient.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ActionButton
|
||||
icon={<Download size={14} />}
|
||||
label="Download CV"
|
||||
href="/cv.pdf"
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Mail size={14} />}
|
||||
label="Email"
|
||||
href={`mailto:${patient.email}`}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Linkedin size={14} />}
|
||||
label="LinkedIn"
|
||||
href={`https://${patient.linkedin}`}
|
||||
external
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CondensedBanner() {
|
||||
return (
|
||||
<div className="h-full px-4 lg:px-6 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<h1 className="font-ui font-semibold text-white text-sm tracking-tight truncate">
|
||||
{patient.name}
|
||||
</h1>
|
||||
<span className="text-slate-500">|</span>
|
||||
<span className="flex items-center gap-1 text-sm text-slate-300">
|
||||
<span className="text-slate-500 font-ui">NHS No:</span>{' '}
|
||||
<NHSNumberWithTooltip condensed />
|
||||
</span>
|
||||
<span className="text-slate-500">|</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusDot status={patient.status} />
|
||||
<span className="text-slate-400 text-xs font-ui">{patient.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<ActionButton
|
||||
icon={<Download size={14} />}
|
||||
label="Download CV"
|
||||
href="/cv.pdf"
|
||||
compact
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Mail size={14} />}
|
||||
label="Email"
|
||||
href={`mailto:${patient.email}`}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Sub-components --- */
|
||||
|
||||
interface NHSNumberWithTooltipProps {
|
||||
condensed?: boolean
|
||||
}
|
||||
|
||||
function NHSNumberWithTooltip({ condensed = false }: NHSNumberWithTooltipProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
timeoutRef.current = setTimeout(() => setShowTooltip(true), 300)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
setShowTooltip(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative inline-flex items-center"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={() => setShowTooltip(true)}
|
||||
onBlur={() => setShowTooltip(false)}
|
||||
>
|
||||
<span
|
||||
className={`font-geist cursor-help border-b border-dotted border-slate-500 ${condensed ? 'text-sm' : ''}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-describedby="nhs-tooltip"
|
||||
>
|
||||
{patient.nhsNumber}
|
||||
</span>
|
||||
<AnimatePresence>
|
||||
{showTooltip && (
|
||||
<motion.span
|
||||
id="nhs-tooltip"
|
||||
role="tooltip"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1 bg-slate-800 text-white text-xs font-ui rounded whitespace-nowrap z-50 shadow-lg pointer-events-none"
|
||||
>
|
||||
{patient.nhsNumberTooltip}
|
||||
<span className="absolute left-1/2 -translate-x-1/2 -top-1 w-2 h-2 bg-slate-800 rotate-45" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusDotProps {
|
||||
status: string
|
||||
}
|
||||
|
||||
function StatusDot({ status }: StatusDotProps) {
|
||||
const colorClass = status === 'Active' ? 'bg-pmr-green' : 'bg-slate-400'
|
||||
return (
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${colorClass} flex-shrink-0`}
|
||||
role="img"
|
||||
aria-label={`Status: ${status}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
badge: string
|
||||
}
|
||||
|
||||
function StatusBadge({ badge }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className="px-2.5 py-0.5 bg-pmr-nhsblue text-white text-xs font-ui font-medium rounded-full">
|
||||
{badge}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function ActionButton({ icon, label, href, external, compact }: ActionButtonProps) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
className={`
|
||||
inline-flex items-center gap-1.5
|
||||
border border-pmr-nhsblue text-pmr-nhsblue
|
||||
hover:bg-pmr-nhsblue hover:text-white
|
||||
transition-colors duration-150
|
||||
rounded
|
||||
font-ui font-medium
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-offset-1 focus-visible:ring-offset-pmr-banner
|
||||
${compact ? 'px-2 py-1 text-xs' : 'px-3 py-1.5 text-sm'}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useScrollReveal } from '@/hooks/useScrollReveal'
|
||||
import type { Project as ProjectType } from '@/types'
|
||||
|
||||
const projectsData: ProjectType[] = [
|
||||
{
|
||||
title: 'PharMetrics',
|
||||
description:
|
||||
'Real-time medicines expenditure dashboard providing actionable analytics for NHS decision-makers.',
|
||||
link: 'https://medicines.charlwood.xyz/',
|
||||
},
|
||||
{
|
||||
title: 'Patient Pathway Analysis',
|
||||
description:
|
||||
'Data-driven analysis of patient pathways to identify optimisation opportunities and improve clinical outcomes.',
|
||||
},
|
||||
{
|
||||
title: 'Blueteq Generator',
|
||||
description:
|
||||
'Automation tool reducing high-cost drug approval processing time by 70%, saving 200+ hours annually.',
|
||||
},
|
||||
{
|
||||
title: 'NMS Video',
|
||||
description:
|
||||
'Educational video resource supporting New Medicine Service consultations, improving patient engagement.',
|
||||
},
|
||||
]
|
||||
|
||||
const ProjectCard = ({
|
||||
project,
|
||||
delay,
|
||||
isVisible,
|
||||
}: {
|
||||
project: ProjectType
|
||||
delay: number
|
||||
isVisible: boolean
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.5, delay, ease: 'easeOut' }}
|
||||
className="group relative bg-white rounded-2xl p-6 shadow-sm overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-2xl p-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00897B, #FF6B6B)',
|
||||
WebkitMask:
|
||||
'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
||||
WebkitMaskComposite: 'xor',
|
||||
maskComposite: 'exclude',
|
||||
}}
|
||||
/>
|
||||
<h3 className="font-primary text-base font-semibold text-heading leading-tight">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text leading-relaxed mt-2">
|
||||
{project.description}
|
||||
</p>
|
||||
{project.link && (
|
||||
<a
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 mt-3 px-4 py-1.5 bg-teal text-white rounded-full text-xs font-medium font-secondary transition-all hover:bg-[#00796B] hover:-translate-y-px"
|
||||
>
|
||||
Visit Project
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Projects() {
|
||||
const [sectionRef, isVisible] = useScrollReveal<HTMLElement>({
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
return (
|
||||
<section id="projects" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
||||
>
|
||||
Projects
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{projectsData.map((project, index) => (
|
||||
<ProjectCard
|
||||
key={project.title}
|
||||
project={project}
|
||||
delay={0.1 + index * 0.1}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import type { Skill } from '../types'
|
||||
import { calculateSkillOffset } from '../lib/utils'
|
||||
|
||||
const GAUGE_RADIUS = 34
|
||||
const GAUGE_CIRCUMFERENCE = 2 * Math.PI * GAUGE_RADIUS
|
||||
|
||||
interface SkillGaugeProps {
|
||||
skill: Skill
|
||||
delay: number
|
||||
isVisible: boolean
|
||||
}
|
||||
|
||||
function SkillGauge({ skill, delay, isVisible }: SkillGaugeProps) {
|
||||
const [animated, setAnimated] = useState(false)
|
||||
const strokeColor = skill.color === 'coral' ? '#FF6B6B' : '#00897B'
|
||||
const hoverBg = skill.color === 'coral' ? 'hover:bg-coral-light' : 'hover:bg-teal-light'
|
||||
|
||||
const targetOffset = calculateSkillOffset(skill.level, GAUGE_RADIUS)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !animated) {
|
||||
const timer = setTimeout(() => setAnimated(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isVisible, animated, delay])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.5, delay: delay / 1000, ease: 'easeOut' }}
|
||||
className={`flex flex-col items-center p-3 xs:p-4 rounded-2xl transition-colors duration-300 ${hoverBg}`}
|
||||
>
|
||||
<svg
|
||||
className="skill-gauge block w-16 h-16 xs:w-20 xs:h-20"
|
||||
viewBox="0 0 80 80"
|
||||
>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r={GAUGE_RADIUS}
|
||||
fill="none"
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="5"
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r={GAUGE_RADIUS}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90, 40, 40)"
|
||||
style={{
|
||||
strokeDasharray: GAUGE_CIRCUMFERENCE,
|
||||
strokeDashoffset: animated ? targetOffset : GAUGE_CIRCUMFERENCE,
|
||||
transition: animated ? 'stroke-dashoffset 1.2s ease-out' : 'none'
|
||||
}}
|
||||
/>
|
||||
<text
|
||||
x="40"
|
||||
y="40"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
fill="#0F172A"
|
||||
fontFamily="'Inter Tight', system-ui, sans-serif"
|
||||
>
|
||||
{skill.level}%
|
||||
</text>
|
||||
</svg>
|
||||
<span className="font-primary text-xs font-semibold text-heading mt-2 text-center leading-tight">
|
||||
{skill.name}
|
||||
</span>
|
||||
<span className="font-secondary text-[10px] text-muted uppercase tracking-wide mt-0.5">
|
||||
{skill.category}
|
||||
</span>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkillCategoryProps {
|
||||
label: string
|
||||
skills: Skill[]
|
||||
isVisible: boolean
|
||||
baseDelay: number
|
||||
}
|
||||
|
||||
function SkillCategory({ label, skills, isVisible, baseDelay }: SkillCategoryProps) {
|
||||
return (
|
||||
<div className="mb-10 last:mb-0">
|
||||
<h3 className="font-secondary text-xs font-semibold uppercase tracking-widest text-muted mb-5 pl-1">
|
||||
{label}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 xs:grid-cols-3 md:grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-4 xs:gap-6">
|
||||
{skills.map((skill, index) => (
|
||||
<SkillGauge
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
delay={baseDelay + index * 100}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const skillsData: Skill[] = [
|
||||
{ name: 'Python', level: 90, category: 'Technical', color: 'teal' },
|
||||
{ name: 'SQL', level: 88, category: 'Technical', color: 'teal' },
|
||||
{ name: 'Power BI', level: 92, category: 'Technical', color: 'teal' },
|
||||
{ name: 'JS / TS', level: 70, category: 'Technical', color: 'teal' },
|
||||
{ name: 'Data Analysis', level: 95, category: 'Technical', color: 'teal' },
|
||||
{ name: 'Dashboard Dev', level: 88, category: 'Technical', color: 'teal' },
|
||||
{ name: 'Algorithm Design', level: 82, category: 'Technical', color: 'teal' },
|
||||
{ name: 'Data Pipelines', level: 80, category: 'Technical', color: 'teal' },
|
||||
|
||||
{ name: 'Medicines Optimisation', level: 95, category: 'Clinical', color: 'coral' },
|
||||
{ name: 'Pop. Health Analytics', level: 90, category: 'Clinical', color: 'coral' },
|
||||
{ name: 'NICE TA', level: 85, category: 'Clinical', color: 'coral' },
|
||||
{ name: 'Health Economics', level: 80, category: 'Clinical', color: 'coral' },
|
||||
{ name: 'Clinical Pathways', level: 82, category: 'Clinical', color: 'coral' },
|
||||
{ name: 'CD Assurance', level: 88, category: 'Clinical', color: 'coral' },
|
||||
|
||||
{ name: 'Budget Mgmt', level: 90, category: 'Strategic', color: 'teal' },
|
||||
{ name: 'Stakeholder Engagement', level: 88, category: 'Strategic', color: 'teal' },
|
||||
{ name: 'Pharma Negotiation', level: 85, category: 'Strategic', color: 'teal' },
|
||||
{ name: 'Team Development', level: 82, category: 'Strategic', color: 'teal' },
|
||||
]
|
||||
|
||||
export function Skills() {
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = sectionRef.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.unobserve(element)
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px' }
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const technicalSkills = skillsData.filter(s => s.category === 'Technical')
|
||||
const clinicalSkills = skillsData.filter(s => s.category === 'Clinical')
|
||||
const strategicSkills = skillsData.filter(s => s.category === 'Strategic')
|
||||
|
||||
return (
|
||||
<section id="skills" ref={sectionRef} className="py-12 xs:py-16 md:py-20">
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isVisible ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
||||
className="font-primary text-2xl font-bold text-heading text-center mb-8"
|
||||
>
|
||||
Skills & Expertise
|
||||
</motion.h2>
|
||||
|
||||
<SkillCategory
|
||||
label="Technical"
|
||||
skills={technicalSkills}
|
||||
isVisible={isVisible}
|
||||
baseDelay={200}
|
||||
/>
|
||||
<SkillCategory
|
||||
label="Clinical"
|
||||
skills={clinicalSkills}
|
||||
isVisible={isVisible}
|
||||
baseDelay={200 + technicalSkills.length * 100 + 100}
|
||||
/>
|
||||
<SkillCategory
|
||||
label="Strategic"
|
||||
skills={strategicSkills}
|
||||
isVisible={isVisible}
|
||||
baseDelay={200 + technicalSkills.length * 100 + clinicalSkills.length * 100 + 200}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
interface NavSection {
|
||||
id: string
|
||||
label: string
|
||||
tileId: string // data-tile-id to scroll to
|
||||
}
|
||||
|
||||
interface SubNavProps {
|
||||
activeSection: string
|
||||
onSectionClick: (sectionId: string) => void
|
||||
}
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{ id: 'overview', label: 'Overview', tileId: 'patient-summary' },
|
||||
{ id: 'skills', label: 'Skills', tileId: 'core-skills' },
|
||||
{ id: 'experience', label: 'Experience', tileId: 'career-activity' },
|
||||
{ id: 'projects', label: 'Projects', tileId: 'projects' },
|
||||
{ id: 'education', label: 'Education', tileId: 'education' },
|
||||
]
|
||||
|
||||
export function SubNav({ activeSection, onSectionClick }: SubNavProps) {
|
||||
const handleSectionClick = (section: NavSection) => {
|
||||
// Scroll to the tile
|
||||
const tileEl = document.querySelector(`[data-tile-id="${section.tileId}"]`)
|
||||
if (tileEl) {
|
||||
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
// Notify parent of section change
|
||||
onSectionClick(section.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Section navigation"
|
||||
className="subnav-scroll"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 'var(--topbar-height)',
|
||||
zIndex: 99,
|
||||
height: 'var(--subnav-height)',
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '24px',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
padding: '0 16px',
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => {
|
||||
const isActive = activeSection === section.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => handleSectionClick(section)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: isActive ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '0 4px 2px',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 200ms ease-out',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
flexShrink: 0,
|
||||
minHeight: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
background: 'var(--accent)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export function TopBar({ onSearchClick }: TopBarProps) {
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}}
|
||||
>
|
||||
Dr. A.CHARLWOOD
|
||||
A.RECRUITER
|
||||
</span>
|
||||
<span
|
||||
className="font-geist hidden xs:inline"
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { Consultation } from '@/types/pmr'
|
||||
|
||||
interface ConsultationDetailProps {
|
||||
consultation: Consultation
|
||||
}
|
||||
|
||||
export function ConsultationDetail({ consultation }: ConsultationDetailProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Role header */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{consultation.role}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: consultation.orgColor,
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{consultation.organization}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: 'var(--text-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>{consultation.duration}</span>
|
||||
{consultation.isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
backgroundColor: 'var(--success-light)',
|
||||
color: 'var(--success)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History (presenting complaint) */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
History
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{consultation.history}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Examination (achievements) */}
|
||||
{consultation.examination && consultation.examination.length > 0 && (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Key Achievements
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan (outcomes) */}
|
||||
{consultation.plan && consultation.plan.length > 0 && (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Outcomes & Impact
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{consultation.plan.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coded entries (technical environment / tags) */}
|
||||
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Coded Entries
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{consultation.codedEntries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: 'var(--bg-dashboard)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{entry.code}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{entry.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { GraduationCap, Award, BookOpen, FlaskConical, type LucideIcon } from 'lucide-react'
|
||||
import type { Document } from '@/types/pmr'
|
||||
import { educationExtras } from '@/data/educationExtras'
|
||||
|
||||
interface EducationDetailProps {
|
||||
document: Document
|
||||
}
|
||||
|
||||
const sectionHeaderStyle: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}
|
||||
|
||||
const typeIconMap: Record<string, LucideIcon> = {
|
||||
Certificate: GraduationCap,
|
||||
Registration: Award,
|
||||
Results: BookOpen,
|
||||
Research: FlaskConical,
|
||||
}
|
||||
|
||||
export function EducationDetail({ document }: EducationDetailProps) {
|
||||
const extra = educationExtras.find((e) => e.documentId === document.id)
|
||||
const Icon = typeIconMap[document.type] || GraduationCap
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: '1.3',
|
||||
}}
|
||||
>
|
||||
{document.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.institution && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: '#7C3AED',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
{document.institution}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: 'var(--text-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{document.duration && <span>{document.duration}</span>}
|
||||
{document.classification && (
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
backgroundColor: 'var(--purple-light, rgba(124,58,237,0.08))',
|
||||
color: '#7C3AED',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{document.classification}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research project (MPharm) */}
|
||||
{extra?.researchDescription && (
|
||||
<div>
|
||||
<h3 style={sectionHeaderStyle}>Research Project</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{extra.researchDescription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSCE score (MPharm) */}
|
||||
{extra?.osceScore && (
|
||||
<div>
|
||||
<h3 style={sectionHeaderStyle}>OSCE Performance</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 14px',
|
||||
backgroundColor: 'var(--success-light)',
|
||||
border: '1px solid var(--success-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
{extra.osceScore}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
Objective Structured Clinical Examination
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracurricular activities (MPharm) */}
|
||||
{extra?.extracurriculars && extra.extracurriculars.length > 0 && (
|
||||
<div>
|
||||
<h3 style={sectionHeaderStyle}>Extracurricular Activities</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{extra.extracurriculars.map((activity, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{activity}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Programme detail (Mary Seacole) */}
|
||||
{extra?.programmeDetail && (
|
||||
<div>
|
||||
<h3 style={sectionHeaderStyle}>Programme Overview</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{extra.programmeDetail}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{document.notes && (
|
||||
<div>
|
||||
<h3 style={sectionHeaderStyle}>Notes</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-secondary)',
|
||||
margin: 0,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{document.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { KPI } from '@/types/pmr'
|
||||
|
||||
interface KPIDetailProps {
|
||||
kpi: KPI
|
||||
}
|
||||
|
||||
// Color map for KPI values
|
||||
const colorMap: Record<KPI['colorVariant'], string> = {
|
||||
green: '#059669',
|
||||
amber: '#D97706',
|
||||
teal: '#0D6E6E',
|
||||
}
|
||||
|
||||
export function KPIDetail({ kpi }: KPIDetailProps) {
|
||||
// If story exists, render rich content; otherwise fallback to explanation
|
||||
if (!kpi.story) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
color: colorMap[kpi.colorVariant],
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{kpi.value}
|
||||
</div>
|
||||
<p>{kpi.explanation}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { context, role, outcomes, period } = kpi.story
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Headline number */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 700,
|
||||
color: colorMap[kpi.colorVariant],
|
||||
lineHeight: '1',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{kpi.value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{kpi.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period badge (if present) */}
|
||||
{period && (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 10px',
|
||||
backgroundColor: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{period}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context paragraph */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Context
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{context}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Your role paragraph */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Your Role
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Outcome bullets */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Key Outcomes
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{outcomes.map((outcome, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{outcome}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
investigation: Investigation
|
||||
}
|
||||
|
||||
const statusColorMap: Record<Investigation['status'], string> = {
|
||||
Complete: '#059669',
|
||||
Ongoing: '#D97706',
|
||||
Live: '#0D6E6E',
|
||||
}
|
||||
|
||||
const statusBgMap: Record<Investigation['status'], string> = {
|
||||
Complete: 'rgba(5,150,105,0.08)',
|
||||
Ongoing: 'rgba(217,119,6,0.08)',
|
||||
Live: 'rgba(10,128,128,0.08)',
|
||||
}
|
||||
|
||||
export function ProjectDetail({ investigation }: ProjectDetailProps) {
|
||||
const statusColor = statusColorMap[investigation.status]
|
||||
const statusBg = statusBgMap[investigation.status]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Header: name + year + status */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{investigation.requestedYear}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 8px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: statusColor,
|
||||
backgroundColor: statusBg,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{investigation.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{investigation.requestingClinician}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Methodology */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Methodology
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{investigation.methodology}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tech stack tags */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Tech Stack
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{investigation.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--accent)',
|
||||
backgroundColor: 'var(--accent-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Results
|
||||
</h3>
|
||||
<ul
|
||||
style={{
|
||||
margin: 0,
|
||||
paddingLeft: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{investigation.results.map((result, index) => (
|
||||
<li
|
||||
key={index}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{result}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* External link */}
|
||||
{investigation.externalUrl && (
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
color: 'var(--surface)',
|
||||
backgroundColor: 'var(--accent)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textDecoration: 'none',
|
||||
alignSelf: 'flex-start',
|
||||
transition: 'background-color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
View Live Project
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { SkillMedication } from '@/types/pmr'
|
||||
import { roleSkillMappings, constellationNodes } from '@/data/constellation'
|
||||
|
||||
interface SkillDetailProps {
|
||||
skill: SkillMedication
|
||||
}
|
||||
|
||||
// Category display names
|
||||
const categoryLabels: Record<SkillMedication['category'], string> = {
|
||||
Technical: 'Technical',
|
||||
Domain: 'Healthcare Domain',
|
||||
Leadership: 'Strategic & Leadership',
|
||||
}
|
||||
|
||||
// Proficiency bar color based on value
|
||||
function getProficiencyColor(proficiency: number): string {
|
||||
if (proficiency >= 90) return 'var(--success)'
|
||||
if (proficiency >= 75) return 'var(--accent)'
|
||||
return 'var(--amber)'
|
||||
}
|
||||
|
||||
export function SkillDetail({ skill }: SkillDetailProps) {
|
||||
// Find roles that use this skill from constellation data
|
||||
const usedInRoles = roleSkillMappings
|
||||
.filter((mapping) => mapping.skillIds.includes(skill.id))
|
||||
.map((mapping) => {
|
||||
const node = constellationNodes.find((n) => n.id === mapping.roleId && n.type === 'role')
|
||||
return node
|
||||
})
|
||||
.filter(Boolean)
|
||||
// Sort chronologically (earliest first)
|
||||
.sort((a, b) => (a!.startYear ?? 0) - (b!.startYear ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
}}
|
||||
>
|
||||
{/* Skill header */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{skill.name}
|
||||
</div>
|
||||
|
||||
{/* Medication metaphor badges */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
backgroundColor: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
}}
|
||||
>
|
||||
{skill.frequency}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
backgroundColor:
|
||||
skill.status === 'Active' ? 'var(--success-light)' : 'var(--bg-dashboard)',
|
||||
color: skill.status === 'Active' ? 'var(--success)' : 'var(--text-tertiary)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{skill.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category label */}
|
||||
<div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-tertiary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
{categoryLabels[skill.category]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Proficiency bar */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Proficiency
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '6px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: getProficiencyColor(skill.proficiency),
|
||||
borderRadius: '3px',
|
||||
transition: 'width 400ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: getProficiencyColor(skill.proficiency),
|
||||
minWidth: '36px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Years of experience */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
Experience
|
||||
</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '6px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
{skill.yearsOfExperience}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{skill.yearsOfExperience === 1 ? 'year' : 'years'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
>
|
||||
Since {skill.startYear}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Used in roles */}
|
||||
{usedInRoles.length > 0 && (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
Used In
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{usedInRoles.map((node) => (
|
||||
<div
|
||||
key={node!.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'var(--bg-dashboard)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: node!.orgColor ?? 'var(--accent)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{node!.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-geist)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginTop: '1px',
|
||||
}}
|
||||
>
|
||||
{node!.organization} · {node!.startYear}
|
||||
{node!.endYear === null ? '–Present' : node!.endYear !== node!.startYear ? `–${node!.endYear}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
BarChart3, Code2, Database, PieChart, FileCode2,
|
||||
Sheet, GitBranch, Workflow, Pill, Users, FileCheck,
|
||||
TrendingUp, Route, ShieldAlert, Banknote, Handshake,
|
||||
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { skills } from '@/data/skills'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { SkillMedication, SkillCategory } from '@/types/pmr'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
BarChart3, Code2, Database, PieChart, FileCode2,
|
||||
Sheet, GitBranch, Workflow, Pill, Users, FileCheck,
|
||||
TrendingUp, Route, ShieldAlert, Banknote, Handshake,
|
||||
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
|
||||
}
|
||||
|
||||
const categoryConfig: { id: SkillCategory; label: string }[] = [
|
||||
{ id: 'Technical', label: 'Technical' },
|
||||
{ id: 'Domain', label: 'Healthcare Domain' },
|
||||
{ id: 'Leadership', label: 'Strategic & Leadership' },
|
||||
]
|
||||
|
||||
interface SkillsAllDetailProps {
|
||||
category?: SkillCategory
|
||||
}
|
||||
|
||||
export function SkillsAllDetail({ category }: SkillsAllDetailProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
// Scroll to highlighted category on mount
|
||||
useEffect(() => {
|
||||
if (category && categoryRefs.current[category]) {
|
||||
categoryRefs.current[category]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [category])
|
||||
|
||||
const groupedSkills = categoryConfig.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
openPanel({ type: 'skill', skill })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'var(--font-ui)', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{groupedSkills.map((group) => {
|
||||
const isHighlighted = category === group.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
ref={(el) => { categoryRefs.current[group.id] = el }}
|
||||
>
|
||||
{/* Category header — matches CoreSkillsTile divider style */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
paddingBottom: '6px',
|
||||
borderBottom: isHighlighted ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: isHighlighted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
background: 'var(--border-light)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{group.skills.length} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skill rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{group.skills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => handleSkillClick(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkillRowProps {
|
||||
skill: SkillMedication
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function SkillRow({ skill, onClick }: SkillRowProps) {
|
||||
const IconComponent = iconMap[skill.icon]
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 10px',
|
||||
background: 'var(--bg-dashboard)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{IconComponent && <IconComponent size={13} />}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{skill.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10.5px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
}}
|
||||
>
|
||||
{skill.frequency} · {skill.yearsOfExperience} yrs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proficiency */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--border-light)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${skill.proficiency}%`,
|
||||
height: '100%',
|
||||
backgroundColor: skill.proficiency >= 90 ? 'var(--success)' : skill.proficiency >= 75 ? 'var(--accent)' : 'var(--amber)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
color: 'var(--text-tertiary)',
|
||||
minWidth: '28px',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{skill.proficiency}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { documents } from '@/data/documents'
|
||||
import { consultations } from '@/data/consultations'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
import { skills } from '@/data/skills'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import CareerConstellation from '../CareerConstellation'
|
||||
|
||||
type ActivityType = 'role' | 'project' | 'cert' | 'edu'
|
||||
|
||||
@@ -140,49 +140,46 @@ const dotColorMap: Record<ActivityType, string> = {
|
||||
edu: '#7C3AED',
|
||||
}
|
||||
|
||||
const borderColorMap: Record<ActivityType, string> = {
|
||||
role: '#0D6E6E',
|
||||
project: '#D97706',
|
||||
cert: '#059669',
|
||||
edu: '#7C3AED',
|
||||
}
|
||||
|
||||
interface ActivityItemProps {
|
||||
entry: ActivityEntry
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onItemClick: () => void
|
||||
}
|
||||
|
||||
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle }) => {
|
||||
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, onItemClick }) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const dotColor = dotColorMap[entry.type]
|
||||
const isExpandable = entry.type === 'role' && entry.consultationId
|
||||
const isClickable = entry.type === 'role' && entry.consultationId
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!isExpandable) return
|
||||
if (!isClickable) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
} else if (e.key === 'Escape' && isExpanded) {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
onItemClick()
|
||||
}
|
||||
},
|
||||
[isExpandable, isExpanded, onToggle],
|
||||
[isClickable, onItemClick],
|
||||
)
|
||||
|
||||
// Get consultation data for expanded content
|
||||
const consultation = isExpandable
|
||||
// Get consultation data for preview text
|
||||
const consultation = isClickable
|
||||
? consultations.find((c) => c.id === entry.consultationId)
|
||||
: null
|
||||
|
||||
// Get preview text (first 1-2 lines from examination)
|
||||
const previewText =
|
||||
consultation && consultation.examination.length > 0
|
||||
? consultation.examination[0]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isExpandable ? 'button' : undefined}
|
||||
tabIndex={isExpandable ? 0 : undefined}
|
||||
aria-expanded={isExpandable ? isExpanded : undefined}
|
||||
onClick={isExpandable ? onToggle : undefined}
|
||||
onKeyDown={isExpandable ? handleKeyDown : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onClick={isClickable ? onItemClick : undefined}
|
||||
onKeyDown={isClickable ? handleKeyDown : undefined}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -190,21 +187,13 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
fontSize: '12px',
|
||||
transition: 'border-color 0.15s',
|
||||
cursor: isExpandable ? 'pointer' : 'default',
|
||||
...(isExpanded && {
|
||||
borderColor: 'var(--accent-border)',
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isExpandable) {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isExpandable && !isExpanded) {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
}
|
||||
transition: 'all 0.15s ease-out',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
transform: isHovered && isClickable ? 'translateY(-1px)' : 'none',
|
||||
boxShadow: isHovered && isClickable
|
||||
? '0 2px 8px rgba(26,43,42,0.08)'
|
||||
: '0 1px 2px rgba(26,43,42,0.05)',
|
||||
borderColor: isHovered && isClickable ? 'var(--accent-border)' : 'var(--border-light)',
|
||||
}}
|
||||
>
|
||||
{/* Item header row */}
|
||||
@@ -249,142 +238,81 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, isExpanded, onToggle
|
||||
>
|
||||
{entry.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && consultation && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{/* Hover preview text for roles */}
|
||||
{isHovered && previewText && (
|
||||
<div
|
||||
style={{
|
||||
borderLeft: `2px solid ${borderColorMap[entry.type]}`,
|
||||
marginLeft: '16px',
|
||||
marginRight: '12px',
|
||||
marginBottom: '12px',
|
||||
paddingLeft: '14px',
|
||||
paddingTop: '4px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '6px',
|
||||
lineHeight: 1.4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{/* Role title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{consultation.role}
|
||||
</div>
|
||||
|
||||
{/* Achievement bullets */}
|
||||
{consultation.examination.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 10px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '5px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((item, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
opacity: 0.5,
|
||||
flexShrink: 0,
|
||||
marginTop: '1px',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Coded entries */}
|
||||
{consultation.codedEntries && consultation.codedEntries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{consultation.codedEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
}}
|
||||
>
|
||||
{entry.code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{previewText}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CareerActivityTile: React.FC = () => {
|
||||
const timeline = buildTimeline()
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||
const handleRoleClick = useCallback(
|
||||
(roleId: string) => {
|
||||
const consultation = consultations.find((c) => c.id === roleId)
|
||||
if (consultation) {
|
||||
openPanel({ type: 'career-role', consultation })
|
||||
}
|
||||
},
|
||||
[],
|
||||
[openPanel],
|
||||
)
|
||||
|
||||
const handleSkillClick = useCallback(
|
||||
(skillId: string) => {
|
||||
const skill = skills.find((s) => s.id === skillId)
|
||||
if (skill) {
|
||||
openPanel({ type: 'skill', skill })
|
||||
}
|
||||
},
|
||||
[openPanel],
|
||||
)
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(entry: ActivityEntry) => {
|
||||
if (entry.type === 'role' && entry.consultationId) {
|
||||
handleRoleClick(entry.consultationId)
|
||||
}
|
||||
},
|
||||
[handleRoleClick],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card full tileId="career-activity">
|
||||
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<CareerConstellation
|
||||
onRoleClick={handleRoleClick}
|
||||
onSkillClick={handleSkillClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="activity-grid">
|
||||
{timeline.map((entry) => (
|
||||
<ActivityItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isExpanded={expandedItemId === entry.id}
|
||||
onToggle={() => handleToggle(entry.id)}
|
||||
onItemClick={() => handleItemClick(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,250 +1,304 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { BarChart3, Code2, Database, PieChart, FileCode2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
BarChart3, Code2, Database, PieChart, FileCode2,
|
||||
Sheet, GitBranch, Workflow, Pill, Users, FileCheck,
|
||||
TrendingUp, Route, ShieldAlert, Banknote, Handshake,
|
||||
MessageSquare, UserPlus, RefreshCw, Calculator, Presentation,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { skills } from '@/data/skills'
|
||||
import { medications } from '@/data/medications'
|
||||
import type { SkillMedication } from '@/types/pmr'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { SkillMedication, SkillCategory } from '@/types/pmr'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const iconMap = {
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Code2,
|
||||
Database,
|
||||
PieChart,
|
||||
FileCode2,
|
||||
Sheet,
|
||||
GitBranch,
|
||||
Workflow,
|
||||
Pill,
|
||||
Users,
|
||||
FileCheck,
|
||||
TrendingUp,
|
||||
Route,
|
||||
ShieldAlert,
|
||||
Banknote,
|
||||
Handshake,
|
||||
MessageSquare,
|
||||
UserPlus,
|
||||
RefreshCw,
|
||||
Calculator,
|
||||
Presentation,
|
||||
}
|
||||
|
||||
interface SkillItemProps {
|
||||
const SKILLS_PER_CATEGORY = 4
|
||||
|
||||
const categoryConfig: { id: SkillCategory; label: string }[] = [
|
||||
{ id: 'Technical', label: 'Technical' },
|
||||
{ id: 'Domain', label: 'Healthcare Domain' },
|
||||
{ id: 'Leadership', label: 'Strategic & Leadership' },
|
||||
]
|
||||
|
||||
interface SkillRowProps {
|
||||
skill: SkillMedication
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
|
||||
const IconComponent = iconMap[skill.icon as keyof typeof iconMap]
|
||||
function SkillRow({ skill, onClick }: SkillRowProps) {
|
||||
const IconComponent = iconMap[skill.icon]
|
||||
|
||||
// Find matching medication for prescribing history
|
||||
const medication = medications.find((m) => m.name === skill.name)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
} else if (e.key === 'Escape' && isExpanded) {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
},
|
||||
[isExpanded, onToggle],
|
||||
)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={onToggle}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${skill.name}: ${skill.frequency}, ${skill.yearsOfExperience} years experience. Click for details.`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: '12.5px',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 10px',
|
||||
minHeight: '44px',
|
||||
background: 'var(--bg-dashboard)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
...(isExpanded && {
|
||||
borderColor: 'var(--accent-border)',
|
||||
}),
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isExpanded) {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
}
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
{/* Item header row */}
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{IconComponent && <IconComponent size={13} />}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{skill.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10.5px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
}}
|
||||
>
|
||||
{skill.frequency} · {skill.yearsOfExperience} yrs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
padding: '2px 7px',
|
||||
borderRadius: '20px',
|
||||
background: 'var(--success-light)',
|
||||
color: 'var(--success)',
|
||||
border: '1px solid var(--success-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{skill.status}
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight
|
||||
size={14}
|
||||
style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
label: string
|
||||
categoryId: SkillCategory
|
||||
skills: SkillMedication[]
|
||||
onSkillClick: (skill: SkillMedication) => void
|
||||
onViewAll: (category: SkillCategory) => void
|
||||
isFirst: boolean
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
label,
|
||||
categoryId,
|
||||
skills: categorySkills,
|
||||
onSkillClick,
|
||||
onViewAll,
|
||||
isFirst,
|
||||
}: CategorySectionProps) {
|
||||
const visibleSkills = categorySkills.slice(0, SKILLS_PER_CATEGORY)
|
||||
const remainingCount = categorySkills.length - SKILLS_PER_CATEGORY
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: isFirst ? 0 : '16px' }}>
|
||||
{/* Category header — sidebar section divider style */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 12px',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{/* Icon container */}
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{IconComponent && <IconComponent size={14} />}
|
||||
</div>
|
||||
|
||||
{/* Text block */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{skill.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
}}
|
||||
>
|
||||
{skill.frequency} · Since {skill.startYear} · {skill.yearsOfExperience} yrs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
padding: '3px 8px',
|
||||
borderRadius: '20px',
|
||||
background: 'var(--success-light)',
|
||||
color: 'var(--success)',
|
||||
border: '1px solid var(--success-border)',
|
||||
flexShrink: 0,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
color: 'var(--text-tertiary)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{skill.status}
|
||||
</div>
|
||||
{label}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
background: 'var(--border-light)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{categorySkills.length} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded content: prescribing history timeline */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && medication && medication.prescribingHistory && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
marginRight: '12px',
|
||||
marginBottom: '12px',
|
||||
paddingLeft: '14px',
|
||||
paddingTop: '4px',
|
||||
borderLeft: '2px solid var(--accent)',
|
||||
}}
|
||||
>
|
||||
{/* Timeline entries */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{medication.prescribingHistory.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
flexShrink: 0,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
/>
|
||||
{/* Skill rows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visibleSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => onSkillClick(skill)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fontFamily: '"Geist Mono", monospace',
|
||||
color: 'var(--text-primary)',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
{entry.year}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{entry.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* View all button */}
|
||||
{remainingCount > 0 && (
|
||||
<button
|
||||
onClick={() => onViewAll(categoryId)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginTop: '8px',
|
||||
padding: '4px 0',
|
||||
minHeight: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'inherit',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
}}
|
||||
aria-label={`View all ${categorySkills.length} ${label} skills`}
|
||||
>
|
||||
View all ({categorySkills.length})
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CoreSkillsTile() {
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||
},
|
||||
[],
|
||||
)
|
||||
// Group skills by category, sorted by proficiency descending
|
||||
const groupedSkills = categoryConfig.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
skills: skills
|
||||
.filter((s) => s.category === id)
|
||||
.sort((a, b) => b.proficiency - a.proficiency),
|
||||
}))
|
||||
|
||||
const handleSkillClick = (skill: SkillMedication) => {
|
||||
openPanel({ type: 'skill', skill })
|
||||
}
|
||||
|
||||
const handleViewAll = (category: SkillCategory) => {
|
||||
openPanel({ type: 'skills-all', category })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card tileId="core-skills">
|
||||
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{skills.map((skill) => (
|
||||
<SkillItem
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
isExpanded={expandedItemId === skill.id}
|
||||
onToggle={() => handleToggle(skill.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Card full tileId="core-skills">
|
||||
<CardHeader
|
||||
dotColor="amber"
|
||||
title="REPEAT MEDICATIONS"
|
||||
rightText="Active prescriptions"
|
||||
/>
|
||||
{groupedSkills.map((group, index) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
label={group.label}
|
||||
categoryId={group.id}
|
||||
skills={group.skills}
|
||||
onSkillClick={handleSkillClick}
|
||||
onViewAll={handleViewAll}
|
||||
isFirst={index === 0}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,158 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { documents } from '@/data/documents'
|
||||
import { educationExtras } from '@/data/educationExtras'
|
||||
|
||||
/**
|
||||
* Education tile - displays academic qualifications
|
||||
* Full-width card below Career Activity
|
||||
* Each entry is clickable to open detail panel
|
||||
*/
|
||||
export function EducationTile() {
|
||||
// Education entries from CV, presented in reverse chronological order
|
||||
const educationEntries = [
|
||||
{
|
||||
degree: 'MPharm (Hons) — 2:1',
|
||||
detail: 'University of East Anglia · 2015',
|
||||
},
|
||||
{
|
||||
degree: 'NHS Leadership Academy — Mary Seacole Programme',
|
||||
detail: '2018 · 78%',
|
||||
},
|
||||
{
|
||||
degree: 'A-Levels: Mathematics (A*), Chemistry (B), Politics (C)',
|
||||
detail: 'Highworth Grammar School · 2009–2011',
|
||||
},
|
||||
const { openPanel } = useDetailPanel()
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||||
|
||||
// Filter to main education entries in reverse chronological order
|
||||
const educationDocuments = [
|
||||
documents.find((d) => d.id === 'doc-mary-seacole')!,
|
||||
documents.find((d) => d.id === 'doc-mpharm')!,
|
||||
documents.find((d) => d.id === 'doc-alevels')!,
|
||||
]
|
||||
|
||||
// Look up education extras by document ID
|
||||
const getExtras = (docId: string) =>
|
||||
educationExtras.find((e) => e.documentId === docId)
|
||||
|
||||
// Build rich inline content for each entry
|
||||
const getInlineDetails = (doc: (typeof educationDocuments)[0]) => {
|
||||
const extras = getExtras(doc.id)
|
||||
|
||||
switch (doc.id) {
|
||||
case 'doc-mpharm':
|
||||
return {
|
||||
title: 'MPharm (Hons) — 2:1',
|
||||
institution: 'University of East Anglia',
|
||||
year: '2011–2015',
|
||||
details: [
|
||||
`Research project: Drug delivery & cocrystals, 75.1% (Distinction)`,
|
||||
...(extras?.osceScore ? [`4th year OSCE: ${extras.osceScore}`] : []),
|
||||
],
|
||||
}
|
||||
case 'doc-mary-seacole':
|
||||
return {
|
||||
title: 'NHS Leadership Academy — Mary Seacole Programme',
|
||||
institution: 'NHS Leadership Academy',
|
||||
year: '2018',
|
||||
details: [
|
||||
`Programme score: 78%`,
|
||||
...(extras?.programmeDetail ? [extras.programmeDetail] : []),
|
||||
],
|
||||
}
|
||||
case 'doc-alevels':
|
||||
return {
|
||||
title: 'A-Levels',
|
||||
institution: 'Highworth Grammar School',
|
||||
year: '2009–2011',
|
||||
details: ['Mathematics (A*) · Chemistry (B) · Politics (C)'],
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: doc.title,
|
||||
institution: doc.institution,
|
||||
year: doc.date,
|
||||
details: doc.classification ? [doc.classification] : [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full tileId="education">
|
||||
<CardHeader dotColor="purple" title="EDUCATION" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{educationEntries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
{educationDocuments.map((doc, index) => {
|
||||
const content = getInlineDetails(doc)
|
||||
const isHovered = hoveredIndex === index
|
||||
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => openPanel({ type: 'education', document: doc })}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
style={{
|
||||
display: 'block',
|
||||
fontWeight: 600,
|
||||
padding: '10px 12px',
|
||||
background: 'var(--surface)',
|
||||
border: `1px solid ${isHovered ? 'var(--accent)' : 'var(--border-light)'}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out',
|
||||
boxShadow: isHovered
|
||||
? '0 2px 8px rgba(26,43,42,0.08)'
|
||||
: '0 1px 2px rgba(26,43,42,0.05)',
|
||||
}}
|
||||
>
|
||||
{entry.degree}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '11px',
|
||||
marginTop: '2px',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{entry.detail}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '12px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, fontSize: '12.5px' }}>
|
||||
{content.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{content.year}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '11px',
|
||||
marginBottom: content.details.length > 0 ? '6px' : '0',
|
||||
}}
|
||||
>
|
||||
{content.institution}
|
||||
</div>
|
||||
{content.details.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
{content.details.map((detail, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: '10.5px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
}}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { consultations } from '@/data/consultations'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
export const LastConsultationTile: React.FC = () => {
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
// Use the most recent consultation (first in array)
|
||||
const consultation = consultations[0]
|
||||
|
||||
const handleOpenPanel = () => {
|
||||
openPanel({ type: 'consultation', consultation })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpenPanel()
|
||||
}
|
||||
}
|
||||
|
||||
// Format date to "May 2025" format
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
@@ -33,8 +48,12 @@ export const LastConsultationTile: React.FC = () => {
|
||||
<Card full tileId="last-consultation">
|
||||
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
|
||||
|
||||
{/* Header info row */}
|
||||
{/* Header info row - clickable */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenPanel}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
@@ -42,7 +61,19 @@ export const LastConsultationTile: React.FC = () => {
|
||||
marginBottom: '14px',
|
||||
paddingBottom: '14px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '8px',
|
||||
margin: '-8px -8px 14px -8px',
|
||||
transition: 'background-color 150ms ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(10,128,128,0.04)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
aria-label={`View full details for ${consultation.role}`}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
@@ -158,6 +189,7 @@ export const LastConsultationTile: React.FC = () => {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '7px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{consultation.examination.map((bullet, index) => (
|
||||
@@ -188,6 +220,36 @@ export const LastConsultationTile: React.FC = () => {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* View full record button */}
|
||||
<button
|
||||
onClick={handleOpenPanel}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '6px 0',
|
||||
minHeight: '44px',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 150ms ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent-hover)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'var(--accent)'
|
||||
}}
|
||||
aria-label="View full consultation record"
|
||||
>
|
||||
<span>View full record</span>
|
||||
<ChevronRight size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React from 'react'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { kpis } from '@/data/kpis'
|
||||
import type { KPI } from '@/types/pmr'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
|
||||
const colorMap: Record<KPI['colorVariant'], string> = {
|
||||
green: '#059669',
|
||||
@@ -11,35 +12,35 @@ const colorMap: Record<KPI['colorVariant'], string> = {
|
||||
|
||||
interface MetricCardProps {
|
||||
kpi: KPI
|
||||
isFlipped: boolean
|
||||
onFlip: (id: string) => void
|
||||
}
|
||||
|
||||
function MetricCard({ kpi, isFlipped, onFlip }: MetricCardProps) {
|
||||
function MetricCard({ kpi }: MetricCardProps) {
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
const handleClick = () => {
|
||||
onFlip(kpi.id)
|
||||
openPanel({ type: 'kpi', kpi })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onFlip(kpi.id)
|
||||
openPanel({ type: 'kpi', kpi })
|
||||
}
|
||||
}
|
||||
|
||||
const outerStyles: React.CSSProperties = {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
const buttonStyles: React.CSSProperties = {
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '16px',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border-light)',
|
||||
background: 'var(--bg-dashboard)',
|
||||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
const innerStyles: React.CSSProperties = {
|
||||
padding: '14px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 150ms ease-out, box-shadow 150ms ease-out',
|
||||
}
|
||||
|
||||
const valueStyles: React.CSSProperties = {
|
||||
fontSize: '22px',
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
lineHeight: 1.2,
|
||||
@@ -47,61 +48,42 @@ function MetricCard({ kpi, isFlipped, onFlip }: MetricCardProps) {
|
||||
}
|
||||
|
||||
const labelStyles: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
marginTop: '3px',
|
||||
color: 'var(--text-primary)',
|
||||
marginTop: '4px',
|
||||
}
|
||||
|
||||
const subStyles: React.CSSProperties = {
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
marginTop: '4px',
|
||||
}
|
||||
|
||||
const backStyles: React.CSSProperties = {
|
||||
padding: '14px',
|
||||
background: 'var(--accent-light)',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
marginTop: '2px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="metric-card"
|
||||
style={outerStyles}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`${kpi.label}: ${kpi.value}. ${isFlipped ? 'Showing explanation. Click to show value.' : 'Click to show explanation.'}`}
|
||||
style={buttonStyles}
|
||||
aria-label={`${kpi.label}: ${kpi.value}. Click to view details.`}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div className={`metric-card-inner${isFlipped ? ' flipped' : ''}`}>
|
||||
<div className="metric-card-front" style={innerStyles}>
|
||||
<div style={valueStyles}>{kpi.value}</div>
|
||||
<div style={labelStyles}>{kpi.label}</div>
|
||||
<div style={subStyles}>{kpi.sub}</div>
|
||||
</div>
|
||||
<div className="metric-card-back" style={backStyles}>
|
||||
{kpi.explanation}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={valueStyles}>{kpi.value}</div>
|
||||
<div style={labelStyles}>{kpi.label}</div>
|
||||
<div style={subStyles}>{kpi.sub}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function LatestResultsTile() {
|
||||
const [flippedCardId, setFlippedCardId] = useState<string | null>(null)
|
||||
|
||||
const handleFlip = useCallback((id: string) => {
|
||||
setFlippedCardId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const gridStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
@@ -113,12 +95,7 @@ export function LatestResultsTile() {
|
||||
<CardHeader dotColor="teal" title="LATEST RESULTS" rightText="Updated May 2025" />
|
||||
<div style={gridStyles}>
|
||||
{kpis.map((kpi) => (
|
||||
<MetricCard
|
||||
key={kpi.id}
|
||||
kpi={kpi}
|
||||
isFlipped={flippedCardId === kpi.id}
|
||||
onFlip={handleFlip}
|
||||
/>
|
||||
<MetricCard key={kpi.id} kpi={kpi} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,18 +1,82 @@
|
||||
import React from 'react'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { personalStatement } from '@/data/profile'
|
||||
|
||||
export function PatientSummaryTile() {
|
||||
const bodyStyles: React.CSSProperties = {
|
||||
// Key statistics from CV_v4.md
|
||||
const highlights = [
|
||||
{ label: '9+ Years', sublabel: 'Professional Experience' },
|
||||
{ label: '1.2M', sublabel: 'Population Served' },
|
||||
{ label: '£220M', sublabel: 'Budget Managed' },
|
||||
{ label: '£14.6M+', sublabel: 'Savings Identified' },
|
||||
]
|
||||
|
||||
const highlightStripStyles: React.CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '12px',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '20px',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}
|
||||
|
||||
const highlightItemStyles: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
}
|
||||
|
||||
const highlightValueStyles: React.CSSProperties = {
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
}
|
||||
|
||||
const highlightLabelStyles: React.CSSProperties = {
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.02em',
|
||||
}
|
||||
|
||||
const profileTextStyles: React.CSSProperties = {
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--text-primary)',
|
||||
}
|
||||
|
||||
// Split profile text into structured sections with bold key phrases
|
||||
const renderProfileWithHierarchy = () => {
|
||||
return (
|
||||
<div style={profileTextStyles}>
|
||||
<strong>Healthcare leader</strong> combining clinical pharmacy expertise with proficiency in{' '}
|
||||
<strong>Python, SQL, and data analytics</strong>, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently{' '}
|
||||
<strong>leading population health analytics for NHS Norfolk & Waveney ICB</strong>, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights—from{' '}
|
||||
<strong>financial scenario modelling</strong> and <strong>pharmaceutical rebate negotiation</strong> to{' '}
|
||||
<strong>algorithm design</strong> and <strong>population-level pathway development</strong>. Proven track record of identifying and prioritising efficiency programmes worth{' '}
|
||||
<strong>£14.6M+</strong> through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for{' '}
|
||||
<strong>executive stakeholders</strong>.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card full tileId="patient-summary">
|
||||
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
|
||||
<div style={bodyStyles}>{personalStatement}</div>
|
||||
|
||||
{/* Highlight strip with key stats */}
|
||||
<div style={highlightStripStyles}>
|
||||
{highlights.map((highlight, idx) => (
|
||||
<div key={idx} style={highlightItemStyles}>
|
||||
<div style={highlightValueStyles}>{highlight.label}</div>
|
||||
<div style={highlightLabelStyles}>{highlight.sublabel}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Profile text with visual hierarchy through bold key phrases */}
|
||||
{renderProfileWithHierarchy()}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { Card, CardHeader } from '../Card'
|
||||
import { useDetailPanel } from '@/contexts/DetailPanelContext'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
Complete: '#059669',
|
||||
Ongoing: '#0D6E6E',
|
||||
@@ -15,11 +12,10 @@ const statusColorMap: Record<string, string> = {
|
||||
|
||||
interface ProjectItemProps {
|
||||
project: Investigation
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||
function ProjectItem({ project, onClick }: ProjectItemProps) {
|
||||
const dotColor = statusColorMap[project.status] || '#0D6E6E'
|
||||
const isLive = project.status === 'Live'
|
||||
|
||||
@@ -27,21 +23,17 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
} else if (e.key === 'Escape' && isExpanded) {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
[isExpanded, onToggle],
|
||||
[onClick],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={onToggle}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -49,30 +41,29 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '10px 12px',
|
||||
minHeight: '44px',
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'border-color 0.15s',
|
||||
transition: 'border-color 0.15s, box-shadow 0.15s',
|
||||
cursor: 'pointer',
|
||||
...(isExpanded && {
|
||||
borderColor: 'var(--accent-border)',
|
||||
}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent-border)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(26,43,42,0.08)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isExpanded) {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
}
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
{/* Item header row */}
|
||||
{/* Row: status dot + name + year */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
padding: '7px 10px',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -87,13 +78,12 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span style={{ flex: 1 }}>{project.name}</span>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{project.name}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginLeft: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -101,164 +91,42 @@ function ProjectItem({ project, isExpanded, onToggle }: ProjectItemProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.2, ease: 'easeOut' }
|
||||
}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
{/* Tech stack tags */}
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
borderLeft: '2px solid #D97706',
|
||||
marginLeft: '14px',
|
||||
marginRight: '10px',
|
||||
marginBottom: '10px',
|
||||
paddingLeft: '12px',
|
||||
paddingTop: '4px',
|
||||
fontSize: '9px',
|
||||
fontFamily: 'var(--font-geist-mono)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{/* Methodology */}
|
||||
{project.methodology && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
margin: '0 0 10px 0',
|
||||
}}
|
||||
>
|
||||
{project.methodology}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tech stack tags */}
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{project.techStack.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
padding: '2px 7px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--amber-light)',
|
||||
color: '#92400E',
|
||||
border: '1px solid var(--amber-border)',
|
||||
}}
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{project.results && project.results.length > 0 && (
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 8px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{project.results.map((result, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-primary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: '#D97706',
|
||||
opacity: 0.6,
|
||||
flexShrink: 0,
|
||||
marginTop: '1px',
|
||||
}}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
{result}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* External link */}
|
||||
{project.externalUrl && (
|
||||
<a
|
||||
href={project.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
fontSize: '10.5px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--accent-light)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(10,128,128,0.14)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--accent-light)'
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
View Results
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectsTile() {
|
||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
setExpandedItemId((prev) => (prev === id ? null : id))
|
||||
},
|
||||
[],
|
||||
)
|
||||
const { openPanel } = useDetailPanel()
|
||||
|
||||
return (
|
||||
<Card full tileId="projects">
|
||||
<Card tileId="projects">
|
||||
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
@@ -266,8 +134,7 @@ export function ProjectsTile() {
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
isExpanded={expandedItemId === project.id}
|
||||
onToggle={() => handleToggle(project.id)}
|
||||
onClick={() => openPanel({ type: 'project', investigation: project })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { consultations } from '@/data/consultations'
|
||||
import type { Consultation, ViewId } from '@/types/pmr'
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConsultationsViewProps {
|
||||
onNavigate?: (view: ViewId, itemId?: string) => void
|
||||
initialExpandedId?: string
|
||||
}
|
||||
|
||||
export function ConsultationsView({ initialExpandedId }: ConsultationsViewProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId ?? null)
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setExpandedId(prev => prev === id ? null : id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-ui font-semibold text-[18px] text-gray-900">
|
||||
Consultation Journal
|
||||
</h1>
|
||||
<span className="font-geist text-[12px] text-gray-500">
|
||||
{consultations.length} entries
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{consultations.map(consultation => (
|
||||
<ConsultationEntry
|
||||
key={consultation.id}
|
||||
consultation={consultation}
|
||||
isExpanded={expandedId === consultation.id}
|
||||
onToggle={() => handleToggle(consultation.id)}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Consultation Entry ─────────────────────────────────────────────────────
|
||||
|
||||
interface ConsultationEntryProps {
|
||||
consultation: Consultation
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
prefersReducedMotion: boolean
|
||||
}
|
||||
|
||||
function ConsultationEntry({
|
||||
consultation,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
prefersReducedMotion,
|
||||
}: ConsultationEntryProps) {
|
||||
const keyCodedEntry = consultation.codedEntries[0]
|
||||
|
||||
return (
|
||||
<article
|
||||
className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden"
|
||||
style={{ borderLeftWidth: '3px', borderLeftColor: consultation.orgColor }}
|
||||
>
|
||||
{/* Collapsed header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-[#EFF6FF] transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:ring-inset"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${consultation.role} at ${consultation.organization}, ${consultation.date}`}
|
||||
>
|
||||
<StatusDot isCurrent={consultation.isCurrent} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-geist text-[13px] text-gray-500">
|
||||
{consultation.date}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span
|
||||
className="font-ui text-[13px]"
|
||||
style={{ color: consultation.orgColor }}
|
||||
>
|
||||
{consultation.organization}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-ui font-semibold text-[15px] text-gray-900 mt-1">
|
||||
{consultation.role}
|
||||
</h3>
|
||||
|
||||
{!isExpanded && keyCodedEntry && (
|
||||
<p className="font-ui text-[13px] text-gray-500 mt-1 line-clamp-1">
|
||||
<span className="font-medium text-gray-400">Key:</span>{' '}
|
||||
<span className="font-geist text-[12px] text-gray-400">
|
||||
[{keyCodedEntry.code}]
|
||||
</span>{' '}
|
||||
{keyCodedEntry.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="flex-shrink-0 mt-1"
|
||||
>
|
||||
<ChevronDown size={18} className="text-gray-400" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{/* Expandable content — height-only animation, NO opacity fade */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
key="expanded"
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{
|
||||
duration: prefersReducedMotion ? 0 : 0.2,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<ExpandedContent consultation={consultation} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Status Dot ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatusDotProps {
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
function StatusDot({ isCurrent }: StatusDotProps) {
|
||||
return (
|
||||
<span
|
||||
className="flex-shrink-0 mt-1.5"
|
||||
aria-label={isCurrent ? 'Current role' : 'Historical role'}
|
||||
>
|
||||
<span
|
||||
className={`block w-2 h-2 rounded-full ${
|
||||
isCurrent ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Expanded Content ───────────────────────────────────────────────────────
|
||||
|
||||
interface ExpandedContentProps {
|
||||
consultation: Consultation
|
||||
}
|
||||
|
||||
function ExpandedContent({ consultation }: ExpandedContentProps) {
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="pl-5 border-l border-[#E5E7EB] ml-1">
|
||||
{/* Duration */}
|
||||
<div className="mb-4">
|
||||
<span className="font-ui text-[13px] text-gray-500">Duration: </span>
|
||||
<span className="font-geist text-[13px] text-gray-700">
|
||||
{consultation.duration}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* HISTORY */}
|
||||
<SectionHeader>HISTORY</SectionHeader>
|
||||
<p className="font-ui text-[13px] text-gray-700 leading-relaxed mb-4">
|
||||
{consultation.history}
|
||||
</p>
|
||||
|
||||
{/* EXAMINATION */}
|
||||
<SectionHeader>EXAMINATION</SectionHeader>
|
||||
<ul className="space-y-1.5 mb-4">
|
||||
{consultation.examination.map((item, index) => (
|
||||
<li key={index} className="flex gap-2 text-[13px]">
|
||||
<span className="text-gray-300 flex-shrink-0">-</span>
|
||||
<span className="font-ui text-gray-700">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* PLAN */}
|
||||
<SectionHeader>PLAN</SectionHeader>
|
||||
<ul className="space-y-1.5 mb-4">
|
||||
{consultation.plan.map((item, index) => (
|
||||
<li key={index} className="flex gap-2 text-[13px]">
|
||||
<span className="text-gray-300 flex-shrink-0">-</span>
|
||||
<span className="font-ui text-gray-700">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CODED ENTRIES */}
|
||||
<SectionHeader>CODED ENTRIES</SectionHeader>
|
||||
<div className="space-y-1">
|
||||
{consultation.codedEntries.map(entry => (
|
||||
<CodedEntry
|
||||
key={entry.code}
|
||||
code={entry.code}
|
||||
description={entry.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section Header ─────────────────────────────────────────────────────────
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="font-ui font-semibold text-[12px] uppercase tracking-[0.05em] text-gray-400 mb-2">
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Coded Entry ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CodedEntryProps {
|
||||
code: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function CodedEntry({ code, description }: CodedEntryProps) {
|
||||
return (
|
||||
<div className="font-geist text-[12px] text-gray-500">
|
||||
[{code}] {description}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, FileText, Award, GraduationCap, FlaskConical } from 'lucide-react'
|
||||
import { documents } from '@/data/documents'
|
||||
import type { Document, DocumentType } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const documentIcons: Record<DocumentType, React.FC<{ className?: string }>> = {
|
||||
Certificate: FileText,
|
||||
Registration: Award,
|
||||
Results: GraduationCap,
|
||||
Research: FlaskConical,
|
||||
}
|
||||
|
||||
function DocumentTypeIcon({ type }: { type: DocumentType }) {
|
||||
const Icon = documentIcons[type]
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Icon className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const documentBorderColors: Record<DocumentType, string> = {
|
||||
Certificate: '#005EB8',
|
||||
Registration: '#10B981',
|
||||
Results: '#6366F1',
|
||||
Research: '#8B5CF6',
|
||||
}
|
||||
|
||||
interface TreeLineProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||
<span className="ml-2 flex-1">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
document: doc,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
index,
|
||||
}: {
|
||||
document: Document
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
index: number
|
||||
}) {
|
||||
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
||||
{ label: 'Type', value: doc.type },
|
||||
{ label: 'Date Awarded', value: doc.date },
|
||||
]
|
||||
|
||||
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
|
||||
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
|
||||
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
|
||||
if (doc.researchDetail) {
|
||||
fields.push({
|
||||
label: 'Research',
|
||||
value: (
|
||||
<>
|
||||
{doc.researchDetail}
|
||||
{doc.researchGrade && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`cursor-pointer transition-colors h-[40px] ${
|
||||
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
|
||||
} hover:bg-[#EFF6FF]`}
|
||||
onClick={onToggle}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${doc.title} — ${doc.type}, ${doc.date}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2 w-12">
|
||||
<DocumentTypeIcon type={doc.type} />
|
||||
</td>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</motion.div>
|
||||
<span className="font-ui text-[14px] text-gray-900">{doc.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||
<span className="font-geist text-[13px] text-gray-500">{doc.date}</span>
|
||||
</td>
|
||||
<td className="border-b border-[#E5E7EB] px-3 py-2">
|
||||
<span className="font-ui text-[13px] text-gray-700">{doc.source}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.tr
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#F9FAFB] p-4 border-l-4"
|
||||
style={{ borderLeftColor: documentBorderColors[doc.type] }}
|
||||
>
|
||||
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||
{fields.map((field, idx) => (
|
||||
<TreeLine
|
||||
key={field.label}
|
||||
label={field.label}
|
||||
value={field.value}
|
||||
isLast={idx === fields.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileDocumentCard({
|
||||
document: doc,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
document: Document
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const fields: Array<{ label: string; value: React.ReactNode }> = [
|
||||
{ label: 'Type', value: doc.type },
|
||||
{ label: 'Date Awarded', value: doc.date },
|
||||
]
|
||||
|
||||
if (doc.institution) fields.push({ label: 'Institution', value: doc.institution })
|
||||
if (doc.classification) fields.push({ label: 'Classification', value: doc.classification })
|
||||
if (doc.duration) fields.push({ label: 'Duration', value: doc.duration })
|
||||
if (doc.researchDetail) {
|
||||
fields.push({
|
||||
label: 'Research',
|
||||
value: (
|
||||
<>
|
||||
{doc.researchDetail}
|
||||
{doc.researchGrade && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-gray-500">Grade: {doc.researchGrade}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
if (doc.notes) fields.push({ label: 'Notes', value: <span className="text-gray-600">{doc.notes}</span> })
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${doc.title} — ${doc.type}, ${doc.date}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DocumentTypeIcon type={doc.type} />
|
||||
<span className="font-ui text-[12px] text-gray-500">{doc.type}</span>
|
||||
</div>
|
||||
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||
{doc.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="font-geist text-[12px] text-gray-500">{doc.date}</span>
|
||||
<span className="text-gray-300">•</span>
|
||||
<span className="font-ui text-[12px] text-gray-500">{doc.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="flex-shrink-0 mt-1"
|
||||
>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
|
||||
style={{ borderLeftColor: documentBorderColors[doc.type] }}
|
||||
>
|
||||
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||
{fields.map((field, idx) => (
|
||||
<TreeLine
|
||||
key={field.label}
|
||||
label={field.label}
|
||||
value={field.value}
|
||||
isLast={idx === fields.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentsView() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
const { setExpandedItem } = useAccessibility()
|
||||
|
||||
const handleToggle = useCallback((id: string, title: string) => {
|
||||
const newId = expandedId === id ? null : id
|
||||
setExpandedId(newId)
|
||||
setExpandedItem(newId ? title : null)
|
||||
}, [expandedId, setExpandedItem])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
|
||||
Attached Documents
|
||||
</h2>
|
||||
<p className="font-ui text-[12px] text-gray-400 mt-1">
|
||||
{documents.length} document{documents.length !== 1 ? 's' : ''} attached. Click a row to view details.
|
||||
</p>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-[#F5F7FA]">
|
||||
{documents.map((doc) => (
|
||||
<MobileDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isExpanded={expandedId === doc.id}
|
||||
onToggle={() => handleToggle(doc.id, doc.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#F9FAFB]">
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-12"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||
>
|
||||
Document
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-20"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-32"
|
||||
>
|
||||
Source
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc, index) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isExpanded={expandedId === doc.id}
|
||||
onToggle={() => handleToggle(doc.id, doc.title)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import type { Investigation } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||
|
||||
type InvestigationStatus = 'Complete' | 'Ongoing' | 'Live'
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
function StatusBadge({ status }: { status: InvestigationStatus }) {
|
||||
const styles: Record<InvestigationStatus, { badge: string; dot: string; label: string }> = {
|
||||
Complete: {
|
||||
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
dot: 'bg-emerald-500',
|
||||
label: 'Complete',
|
||||
},
|
||||
Ongoing: {
|
||||
badge: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
dot: 'bg-amber-500',
|
||||
label: 'Ongoing',
|
||||
},
|
||||
Live: {
|
||||
badge: 'bg-emerald-100 text-emerald-800 border-emerald-200',
|
||||
dot: 'bg-emerald-500',
|
||||
label: 'Live',
|
||||
},
|
||||
}
|
||||
|
||||
const { badge, dot, label } = styles[status]
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium border ${badge}`}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
{status === 'Live' && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
)}
|
||||
<span className={`relative inline-flex rounded-full h-1.5 w-1.5 ${dot}`} />
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface TreeLineProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
function TreeLine({ label, value, isLast = false }: TreeLineProps) {
|
||||
return (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||
<span className="ml-2 flex-1">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeBranch({ label, children, isLast = false }: { label: string; children: React.ReactNode; isLast?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 select-none">{isLast ? '└─ ' : '├─ '}</span>
|
||||
<span className="text-gray-500 shrink-0 min-w-[160px]">{label}:</span>
|
||||
</div>
|
||||
<div className="ml-[18px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InvestigationRow({
|
||||
investigation,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
index,
|
||||
}: {
|
||||
investigation: Investigation
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
index: number
|
||||
}) {
|
||||
const statusBorderColor: Record<InvestigationStatus, string> = {
|
||||
Complete: '#10B981',
|
||||
Ongoing: '#F59E0B',
|
||||
Live: '#10B981',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`cursor-pointer transition-colors h-[40px] ${
|
||||
isExpanded ? 'bg-[#EFF6FF]' : index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
|
||||
} hover:bg-[#EFF6FF]`}
|
||||
onClick={onToggle}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${investigation.name} — ${investigation.status}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</motion.div>
|
||||
<span className="font-ui text-[14px] text-gray-900">{investigation.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||
<span className="font-geist text-[13px] text-gray-500">{investigation.requestedYear}</span>
|
||||
</td>
|
||||
<td className="border-b border-r border-[#E5E7EB] px-3 py-2">
|
||||
<StatusBadge status={investigation.status} />
|
||||
</td>
|
||||
<td className="border-b border-[#E5E7EB] px-3 py-2">
|
||||
<span className="font-ui text-[13px] text-gray-700">{investigation.resultSummary}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.tr
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<td colSpan={4} className="p-0 border-b border-[#E5E7EB]">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#F9FAFB] p-4 border-l-4"
|
||||
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
|
||||
>
|
||||
<div className="font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
|
||||
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
|
||||
<TreeLine
|
||||
label="Status"
|
||||
value={
|
||||
<>
|
||||
{investigation.status}
|
||||
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<TreeLine label="Requesting Clinician" value={investigation.requestingClinician} />
|
||||
<TreeLine label="Methodology" value={investigation.methodology} />
|
||||
<TreeBranch label="Results">
|
||||
{investigation.results.map((result, idx) => (
|
||||
<div key={idx} className="flex">
|
||||
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
|
||||
<span>{result}</span>
|
||||
</div>
|
||||
))}
|
||||
</TreeBranch>
|
||||
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
|
||||
{investigation.externalUrl && (
|
||||
<div className="flex items-center pt-2">
|
||||
<span className="text-gray-400 select-none">└─ </span>
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
|
||||
>
|
||||
View Results
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileInvestigationCard({
|
||||
investigation,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: {
|
||||
investigation: Investigation
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const statusBorderColor: Record<InvestigationStatus, string> = {
|
||||
Complete: '#10B981',
|
||||
Ongoing: '#F59E0B',
|
||||
Live: '#10B981',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset focus-visible:outline-none"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${investigation.name} — ${investigation.status}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||
{investigation.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
<span className="font-geist text-[12px] text-gray-500">{investigation.requestedYear}</span>
|
||||
<StatusBadge status={investigation.status} />
|
||||
</div>
|
||||
<p className="font-ui text-[12px] text-gray-700 mt-2 line-clamp-2">
|
||||
{investigation.resultSummary}
|
||||
</p>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="flex-shrink-0 mt-1"
|
||||
>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div
|
||||
className="px-4 pb-4 border-t border-[#E5E7EB] border-l-4"
|
||||
style={{ borderLeftColor: statusBorderColor[investigation.status] }}
|
||||
>
|
||||
<div className="pt-3 font-geist text-[12px] text-gray-700 leading-relaxed space-y-0.5">
|
||||
<TreeLine label="Date Requested" value={String(investigation.requestedYear)} />
|
||||
<TreeLine label="Date Reported" value={investigation.reportedYear ? String(investigation.reportedYear) : 'Pending'} />
|
||||
<TreeLine
|
||||
label="Status"
|
||||
value={
|
||||
<>
|
||||
{investigation.status}
|
||||
{investigation.status === 'Live' && investigation.externalUrl && (
|
||||
<> — Live at {investigation.externalUrl.replace('https://', '')}</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<TreeLine label="Clinician" value={investigation.requestingClinician} />
|
||||
<TreeLine label="Methodology" value={investigation.methodology} />
|
||||
<TreeBranch label="Results">
|
||||
{investigation.results.map((result, idx) => (
|
||||
<div key={idx} className="flex">
|
||||
<span className="text-gray-400 select-none">{idx === investigation.results.length - 1 ? '└─ ' : '├─ '}</span>
|
||||
<span>{result}</span>
|
||||
</div>
|
||||
))}
|
||||
</TreeBranch>
|
||||
<TreeLine label="Tech Stack" value={investigation.techStack.join(', ')} isLast={!investigation.externalUrl} />
|
||||
</div>
|
||||
{investigation.externalUrl && (
|
||||
<div className="mt-3">
|
||||
<a
|
||||
href={investigation.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 bg-[#005EB8] text-white text-[12px] font-medium rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:outline-none"
|
||||
>
|
||||
View Results
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InvestigationsView() {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
const { setExpandedItem } = useAccessibility()
|
||||
|
||||
const handleToggle = useCallback((id: string, name: string) => {
|
||||
const newId = expandedId === id ? null : id
|
||||
setExpandedId(newId)
|
||||
setExpandedItem(newId ? name : null)
|
||||
}, [expandedId, setExpandedItem])
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-500">
|
||||
Investigation Results
|
||||
</h2>
|
||||
<p className="font-ui text-[12px] text-gray-400 mt-1">
|
||||
{investigations.length} investigation{investigations.length !== 1 ? 's' : ''} on record. Click a row to view full results.
|
||||
</p>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-[#F5F7FA]">
|
||||
{investigations.map((investigation) => (
|
||||
<MobileInvestigationCard
|
||||
key={investigation.id}
|
||||
investigation={investigation}
|
||||
isExpanded={expandedId === investigation.id}
|
||||
onToggle={() => handleToggle(investigation.id, investigation.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#F9FAFB]">
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||
>
|
||||
Test Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-24"
|
||||
>
|
||||
Requested
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-r border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400 w-28"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border-b border-[#E5E7EB] px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-[0.05em] text-gray-400"
|
||||
>
|
||||
Result
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{investigations.map((investigation, index) => (
|
||||
<InvestigationRow
|
||||
key={investigation.id}
|
||||
investigation={investigation}
|
||||
isExpanded={expandedId === investigation.id}
|
||||
onToggle={() => handleToggle(investigation.id, investigation.name)}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, ChevronUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { medications } from '@/data/medications'
|
||||
import type { Medication } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||
|
||||
type SortField = 'name' | 'dose' | 'frequency' | 'startYear' | 'status'
|
||||
type SortDirection = 'asc' | 'desc' | null
|
||||
|
||||
interface SortState {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
type CategoryId = 'Active' | 'Clinical' | 'PRN'
|
||||
|
||||
const categoryTabs: { id: CategoryId; label: string; shortLabel: string }[] = [
|
||||
{ id: 'Active', label: 'Active Medications', shortLabel: 'Active' },
|
||||
{ id: 'Clinical', label: 'Clinical Medications', shortLabel: 'Clinical' },
|
||||
{ id: 'PRN', label: 'PRN (As Required)', shortLabel: 'PRN' },
|
||||
]
|
||||
|
||||
const categoryCounts: Record<CategoryId, number> = {
|
||||
Active: medications.filter(m => m.category === 'Active').length,
|
||||
Clinical: medications.filter(m => m.category === 'Clinical').length,
|
||||
PRN: medications.filter(m => m.category === 'PRN').length,
|
||||
}
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
export function MedicationsView() {
|
||||
const [activeTab, setActiveTab] = useState<CategoryId>('Active')
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||
const [sort, setSort] = useState<SortState>({ field: 'name', direction: null })
|
||||
const { isMobile } = useBreakpoint()
|
||||
const { setExpandedItem } = useAccessibility()
|
||||
|
||||
const filteredMedications = useMemo(() => {
|
||||
return medications.filter(med => med.category === activeTab)
|
||||
}, [activeTab])
|
||||
|
||||
const sortedMedications = useMemo(() => {
|
||||
if (!sort.direction) return filteredMedications
|
||||
|
||||
return [...filteredMedications].sort((a, b) => {
|
||||
let comparison = 0
|
||||
switch (sort.field) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'dose':
|
||||
comparison = a.dose - b.dose
|
||||
break
|
||||
case 'frequency': {
|
||||
const freqOrder: Record<string, number> = { 'Daily': 0, 'Weekly': 1, 'Monthly': 2, 'As needed': 3 }
|
||||
comparison = (freqOrder[a.frequency] ?? 4) - (freqOrder[b.frequency] ?? 4)
|
||||
break
|
||||
}
|
||||
case 'startYear':
|
||||
comparison = a.startYear - b.startYear
|
||||
break
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status)
|
||||
break
|
||||
}
|
||||
return sort.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}, [filteredMedications, sort])
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sort.field === field) {
|
||||
if (sort.direction === 'asc') {
|
||||
setSort({ field, direction: 'desc' })
|
||||
} else if (sort.direction === 'desc') {
|
||||
setSort({ field, direction: null })
|
||||
} else {
|
||||
setSort({ field, direction: 'asc' })
|
||||
}
|
||||
} else {
|
||||
setSort({ field, direction: 'asc' })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRow = (id: string, name: string) => {
|
||||
const nextExpanded = expandedRow === id ? null : id
|
||||
setExpandedRow(nextExpanded)
|
||||
setExpandedItem(nextExpanded ? name : null)
|
||||
}
|
||||
|
||||
const SortIndicator = ({ field }: { field: SortField }) => {
|
||||
if (sort.field !== field || !sort.direction) {
|
||||
return <ChevronsUpDown className="w-3.5 h-3.5 text-gray-400" />
|
||||
}
|
||||
return sort.direction === 'asc'
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-[#005EB8]" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-[#005EB8]" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
<h1 className="font-ui font-semibold text-[15px] text-gray-900">
|
||||
Current Medications
|
||||
</h1>
|
||||
<p className="font-ui text-[13px] text-gray-500 mt-0.5">
|
||||
Skills mapped as active medications — proficiency shown as dosage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="border-b border-[#E5E7EB]">
|
||||
<nav className="flex" role="tablist" aria-label="Medication categories">
|
||||
{categoryTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id)
|
||||
setExpandedRow(null)
|
||||
setExpandedItem(null)
|
||||
}}
|
||||
className={`
|
||||
flex-1 px-4 py-2.5 transition-colors duration-100 text-left
|
||||
border-b-2
|
||||
${activeTab === tab.id
|
||||
? 'bg-white border-[#005EB8]'
|
||||
: 'bg-[#F9FAFB] border-transparent text-gray-600 hover:bg-white'}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={`font-ui font-medium text-[14px] ${activeTab === tab.id ? 'text-[#005EB8]' : 'text-gray-600'}`}>
|
||||
{isMobile ? tab.shortLabel : tab.label}
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-[11px] font-ui font-medium
|
||||
${activeTab === tab.id
|
||||
? 'bg-[#005EB8]/10 text-[#005EB8]'
|
||||
: 'bg-gray-200 text-gray-500'}
|
||||
`}
|
||||
>
|
||||
{categoryCounts[tab.id]}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Panel */}
|
||||
<div
|
||||
id={`panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileMedicationList
|
||||
medications={sortedMedications}
|
||||
expandedRow={expandedRow}
|
||||
onToggle={toggleRow}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
{(['name', 'dose', 'frequency', 'startYear', 'status'] as SortField[]).map((field) => {
|
||||
const labels: Record<SortField, string> = {
|
||||
name: 'Drug Name',
|
||||
dose: 'Dose',
|
||||
frequency: 'Frequency',
|
||||
startYear: 'Start',
|
||||
status: 'Status',
|
||||
}
|
||||
return (
|
||||
<th key={field} scope="col" className="text-left border-r border-[#E5E7EB] last:border-r-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort(field)}
|
||||
className="w-full px-4 h-[40px] flex items-center gap-2 hover:bg-[#EFF6FF] transition-colors duration-100"
|
||||
>
|
||||
<span className="font-ui font-semibold text-[13px] uppercase tracking-[0.03em] text-gray-400">
|
||||
{labels[field]}
|
||||
</span>
|
||||
<SortIndicator field={field} />
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedMedications.map((med, index) => (
|
||||
<MedicationRow
|
||||
key={med.id}
|
||||
medication={med}
|
||||
isExpanded={expandedRow === med.id}
|
||||
isEven={index % 2 === 1}
|
||||
onToggle={() => toggleRow(med.id, med.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-[#E5E7EB] bg-[#F9FAFB]">
|
||||
<p className="font-ui text-[12px] text-gray-500">
|
||||
{sortedMedications.length} medications in this category. {isMobile ? 'Tap' : 'Click'} a row to view prescribing history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Mobile Card Layout ───────────────────────────────────────────── */
|
||||
|
||||
interface MobileMedicationListProps {
|
||||
medications: Medication[]
|
||||
expandedRow: string | null
|
||||
onToggle: (id: string, name: string) => void
|
||||
}
|
||||
|
||||
function MobileMedicationList({ medications, expandedRow, onToggle }: MobileMedicationListProps) {
|
||||
return (
|
||||
<div className="divide-y divide-[#E5E7EB]">
|
||||
{medications.map((med) => {
|
||||
const isExpanded = expandedRow === med.id
|
||||
return (
|
||||
<div key={med.id} className="bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(med.id, med.name)}
|
||||
className="w-full p-4 text-left hover:bg-[#EFF6FF] transition-colors duration-100 focus-visible:ring-2 focus-visible:ring-[#005EB8]/40 focus-visible:ring-inset"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${med.name}, ${med.dose}% proficiency, ${med.frequency}, since ${med.startYear}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||
{med.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 mt-1.5 font-ui text-[12px] text-gray-500">
|
||||
<span className="font-geist">{med.dose}%</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span>{med.frequency}</span>
|
||||
<span className="text-gray-300">·</span>
|
||||
<span className="font-geist">Since {med.startYear}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<StatusDot status={med.status} />
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pb-4">
|
||||
<PrescribingHistory history={med.prescribingHistory} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Desktop Table Row ────────────────────────────────────────────── */
|
||||
|
||||
interface MedicationRowProps {
|
||||
medication: Medication
|
||||
isExpanded: boolean
|
||||
isEven: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function MedicationRow({ medication, isExpanded, isEven, onToggle }: MedicationRowProps) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`
|
||||
h-[40px] border-b border-[#E5E7EB] cursor-pointer transition-colors duration-100
|
||||
${isEven ? 'bg-[#F9FAFB]' : 'bg-white'}
|
||||
hover:bg-[#EFF6FF]
|
||||
`}
|
||||
onClick={onToggle}
|
||||
role="row"
|
||||
aria-expanded={isExpanded}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-4 py-2 border-r border-[#E5E7EB]">
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ChevronDown size={14} className="text-gray-400" />
|
||||
</motion.div>
|
||||
<span className="font-ui font-medium text-[14px] text-gray-900">
|
||||
{medication.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-r border-[#E5E7EB]">
|
||||
<span className="font-geist text-[13px] text-gray-700">
|
||||
{medication.dose}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-r border-[#E5E7EB]">
|
||||
<span className="font-ui text-[13px] text-gray-700">
|
||||
{medication.frequency}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 border-r border-[#E5E7EB]">
|
||||
<span className="font-geist text-[13px] text-gray-700">
|
||||
{medication.startYear}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<StatusDot status={medication.status} />
|
||||
</td>
|
||||
</tr>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.tr
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<td colSpan={5} className="p-0">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-4 bg-[#F9FAFB] border-b border-[#E5E7EB]">
|
||||
<PrescribingHistory history={medication.prescribingHistory} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Status Dot ───────────────────────────────────────────────────── */
|
||||
|
||||
function StatusDot({ status }: { status: 'Active' | 'Historical' }) {
|
||||
const color = status === 'Active' ? 'bg-[#22C55E]' : 'bg-gray-400'
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${color}`} aria-hidden="true" />
|
||||
<span className="font-ui text-[13px] text-gray-700">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Prescribing History (shared) ─────────────────────────────────── */
|
||||
|
||||
interface PrescribingHistoryProps {
|
||||
history: { year: number; description: string }[]
|
||||
}
|
||||
|
||||
function PrescribingHistory({ history }: PrescribingHistoryProps) {
|
||||
return (
|
||||
<div className="pl-6">
|
||||
<p className="font-ui font-semibold text-[12px] uppercase tracking-[0.05em] text-gray-400 mb-3">
|
||||
Prescribing History
|
||||
</p>
|
||||
<div className="relative">
|
||||
{/* Vertical timeline line */}
|
||||
<div className="absolute left-[18px] top-1 bottom-1 w-px bg-[#E5E7EB]" aria-hidden="true" />
|
||||
<div className="space-y-2">
|
||||
{history.map((entry, index) => (
|
||||
<div key={index} className="flex gap-4 relative">
|
||||
{/* Timeline dot */}
|
||||
<div className="relative z-10 flex-shrink-0 mt-1.5">
|
||||
<span className="block w-2 h-2 rounded-full bg-[#005EB8] ring-2 ring-white" aria-hidden="true" />
|
||||
</div>
|
||||
<span className="font-geist font-semibold text-[12px] text-gray-600 w-10 flex-shrink-0 pt-[1px]">
|
||||
{entry.year}
|
||||
</span>
|
||||
<span className="font-geist text-[12px] text-gray-500 pt-[1px]">
|
||||
{entry.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { ChevronDown, ExternalLink } from 'lucide-react'
|
||||
import { problems } from '@/data/problems'
|
||||
import { consultations } from '@/data/consultations'
|
||||
import type { Problem, Consultation } from '@/types/pmr'
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint'
|
||||
import { useAccessibility } from '@/contexts/AccessibilityContext'
|
||||
|
||||
interface ProblemsViewProps {
|
||||
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||
}
|
||||
|
||||
type ProblemStatus = 'Active' | 'In Progress' | 'Resolved'
|
||||
|
||||
function TrafficLight({ status }: { status: ProblemStatus }) {
|
||||
const colorMap: Record<ProblemStatus, { bg: string; label: string }> = {
|
||||
Active: { bg: 'bg-green-500', label: 'Active' },
|
||||
'In Progress': { bg: 'bg-amber-500', label: 'In Progress' },
|
||||
Resolved: { bg: 'bg-green-500', label: 'Resolved' },
|
||||
}
|
||||
|
||||
const { bg, label } = colorMap[status]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${bg}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-ui text-xs text-gray-600">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
function ProblemRow({
|
||||
problem,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
showOutcome,
|
||||
}: {
|
||||
problem: Problem
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||
showOutcome: boolean
|
||||
}) {
|
||||
const linkedConsultations = (problem.linkedConsultations ?? [])
|
||||
.map((id) => consultations.find((c) => c.id === id))
|
||||
.filter((c): c is Consultation => c !== undefined)
|
||||
|
||||
const handleLinkedClick = (consultationId: string) => {
|
||||
if (onNavigate) {
|
||||
onNavigate('consultations', consultationId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.tr
|
||||
className={`cursor-pointer hover:bg-[#EFF6FF] transition-colors ${
|
||||
isExpanded ? 'bg-[#EFF6FF]' : ''
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
initial={false}
|
||||
>
|
||||
<td className="border border-gray-200 px-3 py-2.5">
|
||||
<TrafficLight status={problem.status} />
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2.5">
|
||||
<span className="font-geist text-xs text-gray-500">[{problem.code}]</span>
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2.5">
|
||||
<span className="font-ui text-[14px] text-gray-900">{problem.description}</span>
|
||||
</td>
|
||||
<td className="border border-gray-200 px-3 py-2.5">
|
||||
<span className="font-geist text-xs text-gray-500">
|
||||
{problem.resolved || problem.since}
|
||||
</span>
|
||||
</td>
|
||||
{showOutcome && (
|
||||
<td className="border border-gray-200 px-3 py-2.5">
|
||||
{problem.outcome && (
|
||||
<span className="font-ui text-[13px] text-gray-700">{problem.outcome}</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="border border-gray-200 px-3 py-2.5 w-10">
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="inline-block"
|
||||
>
|
||||
<button
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.tr
|
||||
key={`${problem.id}-expanded`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<td colSpan={showOutcome ? 6 : 5} className="p-0 border border-gray-200">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="bg-gray-50 p-4">
|
||||
<div className="font-ui text-[14px] text-gray-700 leading-relaxed mb-4">
|
||||
{problem.narrative}
|
||||
</div>
|
||||
{linkedConsultations.length > 0 && (
|
||||
<div>
|
||||
<span className="font-ui text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Linked Consultations:
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{linkedConsultations.map((consultation) => (
|
||||
<button
|
||||
key={consultation.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLinkedClick(consultation.id)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{consultation.organization} — {consultation.role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</td>
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileProblemCard({
|
||||
problem,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onNavigate,
|
||||
showOutcome,
|
||||
}: {
|
||||
problem: Problem
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onNavigate?: (view: 'consultations', itemId?: string) => void
|
||||
showOutcome: boolean
|
||||
}) {
|
||||
const linkedConsultations = (problem.linkedConsultations ?? [])
|
||||
.map((id) => consultations.find((c) => c.id === id))
|
||||
.filter((c): c is Consultation => c !== undefined)
|
||||
|
||||
const handleLinkedClick = (consultationId: string) => {
|
||||
if (onNavigate) {
|
||||
onNavigate('consultations', consultationId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded shadow-pmr">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full p-4 text-left focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrafficLight status={problem.status} />
|
||||
<span className="font-geist text-xs text-gray-500">[{problem.code}]</span>
|
||||
</div>
|
||||
<h3 className="font-ui font-medium text-[14px] text-gray-900">
|
||||
{problem.description}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-gray-500 font-ui">
|
||||
<span>{showOutcome ? 'Resolved' : 'Since'}: {problem.resolved || problem.since}</span>
|
||||
{showOutcome && problem.outcome && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-gray-700">{problem.outcome}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2 }}
|
||||
className="flex-shrink-0 mt-1"
|
||||
>
|
||||
<ChevronDown size={16} className="text-gray-400" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: prefersReducedMotion ? 0 : 0.2, ease: 'easeOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
className="border-t border-gray-100"
|
||||
>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="pt-3 font-ui text-[14px] text-gray-700 leading-relaxed">
|
||||
{problem.narrative}
|
||||
</div>
|
||||
{linkedConsultations.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="font-ui text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
Linked Consultations:
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{linkedConsultations.map((consultation) => (
|
||||
<button
|
||||
key={consultation.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLinkedClick(consultation.id)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{consultation.organization} — {consultation.role}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProblemsView({ onNavigate }: ProblemsViewProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const { isMobile } = useBreakpoint()
|
||||
const { setExpandedItem } = useAccessibility()
|
||||
|
||||
const activeProblems = problems.filter(
|
||||
(p) => p.status === 'Active' || p.status === 'In Progress'
|
||||
)
|
||||
const resolvedProblems = problems.filter((p) => p.status === 'Resolved')
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string) => {
|
||||
const newExpandedId = expandedId === id ? null : id
|
||||
setExpandedId(newExpandedId)
|
||||
|
||||
// Update breadcrumb context - pass the problem description as the expanded item ID
|
||||
if (newExpandedId) {
|
||||
const problem = problems.find((p) => p.id === newExpandedId)
|
||||
if (problem) {
|
||||
setExpandedItem(problem.description)
|
||||
}
|
||||
} else {
|
||||
setExpandedItem(null)
|
||||
}
|
||||
},
|
||||
[expandedId, setExpandedItem]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden shadow-pmr">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-500">
|
||||
Active Problems
|
||||
</h2>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{activeProblems.map((problem) => (
|
||||
<MobileProblemCard
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
onToggle={() => handleToggle(problem.id)}
|
||||
onNavigate={onNavigate}
|
||||
showOutcome={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Code
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Since
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeProblems.map((problem) => (
|
||||
<ProblemRow
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
onToggle={() => handleToggle(problem.id)}
|
||||
onNavigate={onNavigate}
|
||||
showOutcome={false}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{activeProblems.length === 0 && (
|
||||
<div className="p-4 font-ui text-[14px] text-gray-500 text-center">No active problems</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded overflow-hidden shadow-pmr">
|
||||
<div className="bg-gray-50 border-b border-gray-200 px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-500">
|
||||
Resolved Problems
|
||||
</h2>
|
||||
</div>
|
||||
{isMobile ? (
|
||||
<div className="p-3 space-y-3 bg-pmr-content">
|
||||
{resolvedProblems.map((problem) => (
|
||||
<MobileProblemCard
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
onToggle={() => handleToggle(problem.id)}
|
||||
onNavigate={onNavigate}
|
||||
showOutcome={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Code
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Problem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-28"
|
||||
>
|
||||
Resolved
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Outcome
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="border border-gray-200 px-3 py-2 text-left font-ui font-semibold text-[13px] uppercase tracking-wider text-gray-400 w-10"
|
||||
>
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resolvedProblems.map((problem) => (
|
||||
<ProblemRow
|
||||
key={problem.id}
|
||||
problem={problem}
|
||||
isExpanded={expandedId === problem.id}
|
||||
onToggle={() => handleToggle(problem.id)}
|
||||
onNavigate={onNavigate}
|
||||
showOutcome={true}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{resolvedProblems.length === 0 && (
|
||||
<div className="p-4 font-ui text-[14px] text-gray-500 text-center">No resolved problems</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Send, Mail, Phone, MapPin, ExternalLink, Loader2, CheckCircle } from 'lucide-react'
|
||||
import { patient } from '@/data/patient'
|
||||
|
||||
type Priority = 'urgent' | 'routine' | 'two-week-wait'
|
||||
type ContactMethod = 'email' | 'phone' | 'linkedin'
|
||||
|
||||
interface FormData {
|
||||
priority: Priority
|
||||
referrerName: string
|
||||
referrerEmail: string
|
||||
referrerOrg: string
|
||||
reason: string
|
||||
contactMethod: ContactMethod
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
referrerName?: string
|
||||
referrerEmail?: string
|
||||
}
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
function generateRefNumber(): string {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const seq = String(Math.floor(Math.random() * 999) + 1).padStart(3, '0')
|
||||
return `REF-${year}-${month}${day}-${seq}`
|
||||
}
|
||||
|
||||
function PriorityOption({
|
||||
value,
|
||||
label,
|
||||
selected,
|
||||
tooltip,
|
||||
onSelect,
|
||||
}: {
|
||||
value: Priority
|
||||
label: string
|
||||
selected: boolean
|
||||
tooltip: string
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const dotColors: Record<Priority, string> = {
|
||||
urgent: 'bg-red-500',
|
||||
routine: 'bg-pmr-nhsblue',
|
||||
'two-week-wait': 'bg-amber-500',
|
||||
}
|
||||
|
||||
const labelColors: Record<Priority, string> = {
|
||||
urgent: 'text-red-600',
|
||||
routine: 'text-pmr-nhsblue',
|
||||
'two-week-wait': 'text-amber-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer group relative">
|
||||
<input
|
||||
type="radio"
|
||||
name="priority"
|
||||
value={value}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
selected ? 'border-current' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selected && <span className={`w-2 h-2 rounded-full ${dotColors[value]}`} />}
|
||||
</span>
|
||||
<span className={`font-ui text-sm font-medium ${labelColors[value]}`}>{label}</span>
|
||||
<span
|
||||
className="absolute left-0 bottom-full mb-2 px-2 py-1 bg-gray-900 text-white text-xs font-ui rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"
|
||||
role="tooltip"
|
||||
>
|
||||
{tooltip}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactMethodOption({
|
||||
value,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
value: ContactMethod
|
||||
label: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="contactMethod"
|
||||
value={value}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
selected ? 'border-pmr-nhsblue' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selected && <span className="w-2 h-2 rounded-full bg-pmr-nhsblue" />}
|
||||
</span>
|
||||
<span className="font-ui text-sm text-gray-700">{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
id,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
id: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="block font-ui font-medium text-[13px] text-gray-600">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <p className="font-ui text-xs text-red-600 mt-1">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirectContactTable() {
|
||||
const contactMethods = [
|
||||
{
|
||||
label: 'Email',
|
||||
value: patient.email,
|
||||
href: `mailto:${patient.email}`,
|
||||
action: 'Send Email',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
label: 'Phone',
|
||||
value: patient.phone,
|
||||
href: `tel:${patient.phone}`,
|
||||
action: 'Call',
|
||||
icon: Phone,
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
value: patient.linkedin,
|
||||
href: `https://${patient.linkedin}`,
|
||||
action: 'View Profile',
|
||||
icon: ExternalLink,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: 'Location',
|
||||
value: 'Norwich, UK',
|
||||
href: null,
|
||||
action: null,
|
||||
icon: MapPin,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h3 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
Direct Contact
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-[#E5E7EB]">
|
||||
{contactMethods.map((method) => (
|
||||
<div key={method.label} className="flex items-center justify-between px-4 py-3 hover:bg-[#EFF6FF] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<method.icon className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-ui text-sm text-gray-500 w-20">{method.label}</span>
|
||||
{method.href ? (
|
||||
<a
|
||||
href={method.href}
|
||||
target={method.external ? '_blank' : undefined}
|
||||
rel={method.external ? 'noopener noreferrer' : undefined}
|
||||
className="font-geist text-sm text-pmr-nhsblue hover:underline focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none rounded"
|
||||
>
|
||||
{method.value}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-geist text-sm text-gray-900">{method.value}</span>
|
||||
)}
|
||||
</div>
|
||||
{method.href && (
|
||||
<a
|
||||
href={method.href}
|
||||
target={method.external ? '_blank' : undefined}
|
||||
rel={method.external ? 'noopener noreferrer' : undefined}
|
||||
className="font-ui text-xs text-pmr-nhsblue hover:underline flex items-center gap-1 focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none rounded"
|
||||
>
|
||||
{method.action}
|
||||
{method.external && <ExternalLink className="w-3 h-3" />}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReferralsView() {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
priority: 'routine',
|
||||
referrerName: '',
|
||||
referrerEmail: '',
|
||||
referrerOrg: '',
|
||||
reason: '',
|
||||
contactMethod: 'email',
|
||||
})
|
||||
const [errors, setErrors] = useState<FormErrors>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [refNumber, setRefNumber] = useState('')
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {}
|
||||
|
||||
if (!formData.referrerName.trim()) {
|
||||
newErrors.referrerName = 'Referrer name is required'
|
||||
}
|
||||
if (!formData.referrerEmail.trim()) {
|
||||
newErrors.referrerEmail = 'Referrer email is required'
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.referrerEmail)) {
|
||||
newErrors.referrerEmail = 'Please enter a valid email address'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
setRefNumber(generateRefNumber())
|
||||
setIsSubmitting(false)
|
||||
setIsSuccess(true)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
priority: 'routine',
|
||||
referrerName: '',
|
||||
referrerEmail: '',
|
||||
referrerOrg: '',
|
||||
reason: '',
|
||||
contactMethod: 'email',
|
||||
})
|
||||
setErrors({})
|
||||
setIsSuccess(false)
|
||||
setRefNumber('')
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
New Referral
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-8 text-center">
|
||||
<div
|
||||
className={`inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 ${
|
||||
prefersReducedMotion ? '' : 'animate-[fadeIn_200ms_ease-out]'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="font-ui font-semibold text-lg text-gray-900 mb-2">
|
||||
Referral sent successfully
|
||||
</h3>
|
||||
<p className="font-geist text-sm text-gray-500 mb-1">Reference: {refNumber}</p>
|
||||
<p className="font-ui text-sm text-gray-500 mb-6">
|
||||
Expected response time: 24-48 hours
|
||||
</p>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="font-ui font-medium text-sm px-4 py-2 bg-pmr-nhsblue text-white rounded hover:bg-[#004D9F] transition-colors focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
|
||||
>
|
||||
Send Another Referral
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DirectContactTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-sm uppercase tracking-wider text-gray-500">
|
||||
New Referral
|
||||
</h2>
|
||||
<p className="font-ui text-xs text-gray-400 mt-1">
|
||||
Contact Andy using a clinical referral form format.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<span className="block font-ui font-medium text-[13px] text-gray-600">
|
||||
Referring to
|
||||
</span>
|
||||
<span className="font-ui text-sm text-gray-900">{patient.name}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="block font-ui font-medium text-[13px] text-gray-600">
|
||||
NHS Number
|
||||
</span>
|
||||
<span className="font-geist text-sm text-gray-900">{patient.nhsNumber}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="block font-ui font-medium text-[13px] text-gray-600">
|
||||
Priority
|
||||
</span>
|
||||
<div className="flex gap-6">
|
||||
<PriorityOption
|
||||
value="urgent"
|
||||
label="Urgent"
|
||||
selected={formData.priority === 'urgent'}
|
||||
tooltip="All enquiries are welcome, urgent or not."
|
||||
onSelect={() => setFormData({ ...formData, priority: 'urgent' })}
|
||||
/>
|
||||
<PriorityOption
|
||||
value="routine"
|
||||
label="Routine"
|
||||
selected={formData.priority === 'routine'}
|
||||
tooltip="Standard response timeframe."
|
||||
onSelect={() => setFormData({ ...formData, priority: 'routine' })}
|
||||
/>
|
||||
<PriorityOption
|
||||
value="two-week-wait"
|
||||
label="Two-Week Wait"
|
||||
selected={formData.priority === 'two-week-wait'}
|
||||
tooltip="NHS cancer referral pathway — this isn't that, but the spirit of promptness applies."
|
||||
onSelect={() => setFormData({ ...formData, priority: 'two-week-wait' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Referrer Name"
|
||||
id="referrerName"
|
||||
required
|
||||
error={errors.referrerName}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="referrerName"
|
||||
value={formData.referrerName}
|
||||
onChange={(e) => setFormData({ ...formData, referrerName: e.target.value })}
|
||||
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Referrer Email"
|
||||
id="referrerEmail"
|
||||
required
|
||||
error={errors.referrerEmail}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="referrerEmail"
|
||||
value={formData.referrerEmail}
|
||||
onChange={(e) => setFormData({ ...formData, referrerEmail: e.target.value })}
|
||||
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Referrer Organisation" id="referrerOrg">
|
||||
<input
|
||||
type="text"
|
||||
id="referrerOrg"
|
||||
value={formData.referrerOrg}
|
||||
onChange={(e) => setFormData({ ...formData, referrerOrg: e.target.value })}
|
||||
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200"
|
||||
placeholder="Organisation name (optional)"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Reason for Referral" id="reason">
|
||||
<textarea
|
||||
id="reason"
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData({ ...formData, reason: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full border border-[#D1D5DB] rounded px-3 py-2 text-sm font-ui text-gray-900 placeholder-gray-400 focus:border-pmr-nhsblue focus:ring-2 focus:ring-pmr-nhsblue/15 focus:outline-none transition-all duration-200 resize-y"
|
||||
placeholder="Describe the opportunity or reason for contact..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="block font-ui font-medium text-[13px] text-gray-600">
|
||||
Contact Method
|
||||
</span>
|
||||
<div className="flex gap-6">
|
||||
<ContactMethodOption
|
||||
value="email"
|
||||
label="Email"
|
||||
selected={formData.contactMethod === 'email'}
|
||||
onSelect={() => setFormData({ ...formData, contactMethod: 'email' })}
|
||||
/>
|
||||
<ContactMethodOption
|
||||
value="phone"
|
||||
label="Phone"
|
||||
selected={formData.contactMethod === 'phone'}
|
||||
onSelect={() => setFormData({ ...formData, contactMethod: 'phone' })}
|
||||
/>
|
||||
<ContactMethodOption
|
||||
value="linkedin"
|
||||
label="LinkedIn"
|
||||
selected={formData.contactMethod === 'linkedin'}
|
||||
onSelect={() => setFormData({ ...formData, contactMethod: 'linkedin' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[#E5E7EB]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="font-ui font-medium text-sm px-4 py-2 border border-[#D1D5DB] text-gray-700 rounded hover:bg-gray-50 transition-colors focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="font-ui font-medium text-sm px-6 py-2 bg-pmr-nhsblue text-white rounded hover:bg-[#004D9F] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-pmr-nhsblue/40 focus-visible:outline-none"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Send Referral
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DirectContactTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertTriangle, CheckCircle, ChevronRight } from 'lucide-react'
|
||||
import { patient } from '@/data/patient'
|
||||
import { consultations } from '@/data/consultations'
|
||||
import { problems } from '@/data/problems'
|
||||
import { medications } from '@/data/medications'
|
||||
import type { ViewId, Problem, Medication, Consultation } from '@/types/pmr'
|
||||
|
||||
// ─── Alert state machine ────────────────────────────────────────────────────
|
||||
type AlertState = 'visible' | 'acknowledging' | 'dismissed'
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
interface SummaryViewProps {
|
||||
onNavigate?: (view: ViewId, itemId?: string) => void
|
||||
}
|
||||
|
||||
export function SummaryView({ onNavigate }: SummaryViewProps) {
|
||||
const [alertState, setAlertState] = useState<AlertState>('visible')
|
||||
|
||||
const prefersReducedMotion = typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false
|
||||
|
||||
const handleAcknowledge = useCallback(() => {
|
||||
if (prefersReducedMotion) {
|
||||
setAlertState('dismissed')
|
||||
return
|
||||
}
|
||||
setAlertState('acknowledging')
|
||||
// Icon crossfade (200ms) + hold beat (200ms) = 400ms before collapse
|
||||
const timer = setTimeout(() => {
|
||||
setAlertState('dismissed')
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [prefersReducedMotion])
|
||||
|
||||
const activeProblems = problems.filter(
|
||||
(p) => p.status === 'Active' || p.status === 'In Progress'
|
||||
)
|
||||
const topMedications = medications
|
||||
.filter((m) => m.category === 'Active')
|
||||
.slice(0, 5)
|
||||
const lastConsultation = consultations[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Clinical Alert */}
|
||||
<AnimatePresence>
|
||||
{alertState !== 'dismissed' && (
|
||||
<ClinicalAlert
|
||||
state={alertState}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
prefersReducedMotion={prefersReducedMotion}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Summary cards grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Card 1: Demographics — full width */}
|
||||
<DemographicsCard />
|
||||
|
||||
{/* Card 2: Active Problems — left column */}
|
||||
<ActiveProblemsCard
|
||||
problems={activeProblems}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
{/* Card 3: Current Medications Quick View — right column */}
|
||||
<QuickMedsCard
|
||||
medications={topMedications}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
{/* Card 4: Last Consultation — full width */}
|
||||
<LastConsultationCard
|
||||
consultation={lastConsultation}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Clinical Alert ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ClinicalAlertProps {
|
||||
state: AlertState
|
||||
onAcknowledge: () => void
|
||||
prefersReducedMotion: boolean
|
||||
}
|
||||
|
||||
function ClinicalAlert({
|
||||
state,
|
||||
onAcknowledge,
|
||||
prefersReducedMotion,
|
||||
}: ClinicalAlertProps) {
|
||||
const isAcknowledging = state === 'acknowledging'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
initial={
|
||||
prefersReducedMotion
|
||||
? { y: 0, opacity: 1 }
|
||||
: { y: '-100%', opacity: 0 }
|
||||
}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={
|
||||
prefersReducedMotion
|
||||
? { opacity: 0 }
|
||||
: { height: 0, opacity: 0, marginBottom: 0 }
|
||||
}
|
||||
transition={
|
||||
prefersReducedMotion
|
||||
? { duration: 0 }
|
||||
: state === 'acknowledging'
|
||||
? { duration: 0.2, ease: 'easeOut' }
|
||||
: { type: 'spring', stiffness: 300, damping: 25 }
|
||||
}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex items-start gap-3 p-4 rounded border-l-4"
|
||||
style={{
|
||||
backgroundColor: '#FEF3C7',
|
||||
borderLeftColor: '#F59E0B',
|
||||
}}
|
||||
>
|
||||
{/* Icon area — crossfade between AlertTriangle and CheckCircle */}
|
||||
<div className="flex-shrink-0 mt-0.5 relative w-5 h-5">
|
||||
<AnimatePresence mode="wait">
|
||||
{isAcknowledging ? (
|
||||
<motion.span
|
||||
key="check"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="warning"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<AlertTriangle size={20} className="text-amber-600" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-ui font-medium text-sm" style={{ color: '#92400E' }}>
|
||||
<span className="font-semibold">ALERT:</span> This patient has
|
||||
identified{' '}
|
||||
<span className="font-semibold">£14.6M</span> in prescribing
|
||||
efficiency savings across Norfolk & Waveney ICS.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Acknowledge button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcknowledge}
|
||||
disabled={isAcknowledging}
|
||||
aria-label="Acknowledge clinical alert"
|
||||
className="flex-shrink-0 px-3 py-1.5 text-xs font-ui font-medium border rounded transition-colors duration-100 hover:bg-[#F59E0B] hover:text-white disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: '#F59E0B',
|
||||
color: isAcknowledging ? '#16A34A' : '#92400E',
|
||||
}}
|
||||
>
|
||||
{isAcknowledging ? 'Acknowledged' : 'Acknowledge'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared Card Components ─────────────────────────────────────────────────
|
||||
|
||||
function CardHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="bg-[#F9FAFB] border-b border-[#E5E7EB] px-4 py-3">
|
||||
<h2 className="font-ui font-semibold text-sm uppercase tracking-wide text-gray-500">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Demographics Card ──────────────────────────────────────────────────────
|
||||
|
||||
function DemographicsCard() {
|
||||
return (
|
||||
<div className="lg:col-span-2 bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<CardHeader title="Patient Demographics" />
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-2">
|
||||
<DemographicsRow label="Name" value={patient.displayName} />
|
||||
<DemographicsRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<span>{patient.status}</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DemographicsRow label="DOB" value={patient.dob} mono />
|
||||
<DemographicsRow label="Location" value={patient.address} />
|
||||
<DemographicsRow
|
||||
label="Registration"
|
||||
value={
|
||||
<span>
|
||||
<span className="text-gray-500">GPhC</span>{' '}
|
||||
<span className="font-geist text-[13px]">
|
||||
{patient.nhsNumber.replace(/ /g, '')}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DemographicsRow label="Since" value={patient.registrationYear} mono />
|
||||
<DemographicsRow
|
||||
label="Qualification"
|
||||
value={patient.qualification}
|
||||
/>
|
||||
<DemographicsRow label="University" value={patient.university} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DemographicsRowProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
function DemographicsRow({ label, value, mono }: DemographicsRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-1">
|
||||
<span className="font-ui font-medium text-[13px] text-gray-500 min-w-[100px] text-right flex-shrink-0">
|
||||
{label}:
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm text-gray-900 ${mono ? 'font-geist' : 'font-ui'}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Active Problems Card ───────────────────────────────────────────────────
|
||||
|
||||
interface ActiveProblemsCardProps {
|
||||
problems: Problem[]
|
||||
onNavigate?: (view: ViewId, itemId?: string) => void
|
||||
}
|
||||
|
||||
function ActiveProblemsCard({ problems, onNavigate }: ActiveProblemsCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<CardHeader title="Active Problems" />
|
||||
<div className="divide-y divide-gray-100">
|
||||
{problems.map((problem) => (
|
||||
<button
|
||||
key={problem.id}
|
||||
type="button"
|
||||
onClick={() => onNavigate?.('problems', problem.id)}
|
||||
className="w-full px-4 py-3 flex items-start gap-3 text-left hover:bg-[#EFF6FF] transition-colors duration-100"
|
||||
>
|
||||
<TrafficLight status={problem.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-ui font-medium text-sm text-gray-900 line-clamp-2">
|
||||
{problem.description}
|
||||
</p>
|
||||
{problem.since && (
|
||||
<p className="font-geist text-xs text-gray-500 mt-1">
|
||||
{problem.since}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Traffic Light (always with text label — guardrail) ─────────────────────
|
||||
|
||||
interface TrafficLightProps {
|
||||
status: 'Active' | 'In Progress' | 'Resolved'
|
||||
}
|
||||
|
||||
function TrafficLight({ status }: TrafficLightProps) {
|
||||
const config: Record<
|
||||
TrafficLightProps['status'],
|
||||
{ dotClass: string; label: string }
|
||||
> = {
|
||||
Active: { dotClass: 'bg-green-500', label: 'Active' },
|
||||
'In Progress': { dotClass: 'bg-amber-500', label: 'In Progress' },
|
||||
Resolved: { dotClass: 'bg-green-500', label: 'Resolved' },
|
||||
}
|
||||
const { dotClass, label } = config[status]
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${dotClass}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-ui text-xs text-gray-500">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Quick Medications Card ─────────────────────────────────────────────────
|
||||
|
||||
interface QuickMedsCardProps {
|
||||
medications: Medication[]
|
||||
onNavigate?: (view: ViewId) => void
|
||||
}
|
||||
|
||||
function QuickMedsCard({ medications, onNavigate }: QuickMedsCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<CardHeader title="Current Medications (Quick View)" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB]">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Drug
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Dose
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Freq
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-4 py-2 text-left font-ui font-semibold text-xs uppercase tracking-wider text-gray-400"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{medications.map((med, index) => (
|
||||
<tr
|
||||
key={med.id}
|
||||
className={`${
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-[#F9FAFB]'
|
||||
} hover:bg-[#EFF6FF] transition-colors duration-100`}
|
||||
style={{ height: '40px' }}
|
||||
>
|
||||
<td className="px-4 py-2 font-ui text-sm text-gray-900">
|
||||
{med.name}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-geist text-[13px] text-gray-700">
|
||||
{med.dose}%
|
||||
</td>
|
||||
<td className="px-4 py-2 font-ui text-sm text-gray-700">
|
||||
{med.frequency}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-green-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-ui text-xs text-gray-600">
|
||||
{med.status}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-[#E5E7EB]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate?.('medications')}
|
||||
className="flex items-center gap-1 font-ui text-sm text-pmr-nhsblue hover:underline"
|
||||
>
|
||||
View Full List
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Last Consultation Card ─────────────────────────────────────────────────
|
||||
|
||||
interface LastConsultationCardProps {
|
||||
consultation: Consultation
|
||||
onNavigate?: (view: ViewId, itemId?: string) => void
|
||||
}
|
||||
|
||||
function LastConsultationCard({
|
||||
consultation,
|
||||
onNavigate,
|
||||
}: LastConsultationCardProps) {
|
||||
return (
|
||||
<div className="lg:col-span-2 bg-white border border-[#E5E7EB] rounded shadow-pmr">
|
||||
<CardHeader title="Last Consultation" />
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 mb-2">
|
||||
<span className="font-geist text-[12px]">
|
||||
{consultation.date}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="font-ui text-pmr-nhsblue">
|
||||
{consultation.organization}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-ui font-semibold text-[15px] text-gray-900 mb-2">
|
||||
{consultation.role}
|
||||
</h3>
|
||||
<p className="font-ui text-sm text-gray-600 leading-relaxed line-clamp-3">
|
||||
{consultation.history}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate?.('consultations', consultation.id)}
|
||||
className="flex-shrink-0 flex items-center gap-1 font-ui text-sm text-pmr-nhsblue hover:underline"
|
||||
>
|
||||
View Full Record
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { DetailPanelContent } from '@/types/pmr'
|
||||
|
||||
interface DetailPanelContextValue {
|
||||
content: DetailPanelContent | null
|
||||
openPanel: (content: DetailPanelContent) => void
|
||||
closePanel: () => void
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const DetailPanelContext = createContext<DetailPanelContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
interface DetailPanelProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DetailPanelProvider({ children }: DetailPanelProviderProps) {
|
||||
const [content, setContent] = useState<DetailPanelContent | null>(null)
|
||||
|
||||
const openPanel = (newContent: DetailPanelContent) => {
|
||||
setContent(newContent)
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
setContent(null)
|
||||
}
|
||||
|
||||
const isOpen = content !== null
|
||||
|
||||
const value: DetailPanelContextValue = {
|
||||
content,
|
||||
openPanel,
|
||||
closePanel,
|
||||
isOpen,
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</DetailPanelContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDetailPanel(): DetailPanelContextValue {
|
||||
const context = useContext(DetailPanelContext)
|
||||
if (!context) {
|
||||
throw new Error('useDetailPanel must be used within DetailPanelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import type { ConstellationNode, ConstellationLink } from '@/types/pmr'
|
||||
|
||||
/**
|
||||
* Role-skill mapping for the career constellation graph.
|
||||
* Maps consultation IDs to the skill IDs used/developed in each role.
|
||||
*/
|
||||
export interface RoleSkillMapping {
|
||||
roleId: string // matches consultation.id
|
||||
skillIds: string[] // matches skill IDs from skills.ts
|
||||
}
|
||||
|
||||
export const roleSkillMappings: RoleSkillMapping[] = [
|
||||
{
|
||||
roleId: 'duty-pharmacist-2016',
|
||||
skillIds: [
|
||||
'medicines-optimisation',
|
||||
'team-development',
|
||||
'excel',
|
||||
],
|
||||
},
|
||||
{
|
||||
roleId: 'pharmacy-manager-2017',
|
||||
skillIds: [
|
||||
'medicines-optimisation',
|
||||
'team-development',
|
||||
'data-analysis',
|
||||
'excel',
|
||||
'change-management',
|
||||
'budget-management',
|
||||
'stakeholder-engagement',
|
||||
],
|
||||
},
|
||||
{
|
||||
roleId: 'high-cost-drugs-2022',
|
||||
skillIds: [
|
||||
'medicines-optimisation',
|
||||
'nice-ta',
|
||||
'clinical-pathways',
|
||||
'health-economics',
|
||||
'python',
|
||||
'data-analysis',
|
||||
'sql',
|
||||
'algorithm-design',
|
||||
'stakeholder-engagement',
|
||||
],
|
||||
},
|
||||
{
|
||||
roleId: 'deputy-head-2024',
|
||||
skillIds: [
|
||||
'population-health',
|
||||
'medicines-optimisation',
|
||||
'data-analysis',
|
||||
'python',
|
||||
'sql',
|
||||
'power-bi',
|
||||
'controlled-drugs',
|
||||
'budget-management',
|
||||
'financial-modelling',
|
||||
'pharma-negotiation',
|
||||
'stakeholder-engagement',
|
||||
'team-development',
|
||||
'executive-comms',
|
||||
],
|
||||
},
|
||||
{
|
||||
roleId: 'interim-head-2025',
|
||||
skillIds: [
|
||||
'population-health',
|
||||
'medicines-optimisation',
|
||||
'data-analysis',
|
||||
'python',
|
||||
'sql',
|
||||
'algorithm-design',
|
||||
'data-pipelines',
|
||||
'budget-management',
|
||||
'financial-modelling',
|
||||
'stakeholder-engagement',
|
||||
'executive-comms',
|
||||
'change-management',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Constellation nodes for the D3 force graph.
|
||||
* Includes both role nodes and skill nodes.
|
||||
*/
|
||||
export const constellationNodes: ConstellationNode[] = [
|
||||
// Role nodes (6 roles)
|
||||
{
|
||||
id: 'duty-pharmacist-2016',
|
||||
type: 'role',
|
||||
label: 'Duty Pharmacy Manager',
|
||||
shortLabel: 'Duty Mgr',
|
||||
organization: 'Tesco PLC',
|
||||
startYear: 2016,
|
||||
endYear: 2017,
|
||||
orgColor: '#00897B',
|
||||
},
|
||||
{
|
||||
id: 'pharmacy-manager-2017',
|
||||
type: 'role',
|
||||
label: 'Pharmacy Manager',
|
||||
shortLabel: 'Pharm Mgr',
|
||||
organization: 'Tesco PLC',
|
||||
startYear: 2017,
|
||||
endYear: 2022,
|
||||
orgColor: '#00897B',
|
||||
},
|
||||
{
|
||||
id: 'high-cost-drugs-2022',
|
||||
type: 'role',
|
||||
label: 'High-Cost Drugs & Interface Pharmacist',
|
||||
shortLabel: 'HCD Pharm',
|
||||
organization: 'NHS Norfolk & Waveney ICB',
|
||||
startYear: 2022,
|
||||
endYear: 2024,
|
||||
orgColor: '#005EB8',
|
||||
},
|
||||
{
|
||||
id: 'deputy-head-2024',
|
||||
type: 'role',
|
||||
label: 'Deputy Head, Population Health & Data Analysis',
|
||||
shortLabel: 'Deputy Head',
|
||||
organization: 'NHS Norfolk & Waveney ICB',
|
||||
startYear: 2024,
|
||||
endYear: null,
|
||||
orgColor: '#005EB8',
|
||||
},
|
||||
{
|
||||
id: 'interim-head-2025',
|
||||
type: 'role',
|
||||
label: 'Interim Head, Population Health & Data Analysis',
|
||||
shortLabel: 'Interim Head',
|
||||
organization: 'NHS Norfolk & Waveney ICB',
|
||||
startYear: 2025,
|
||||
endYear: 2025,
|
||||
orgColor: '#005EB8',
|
||||
},
|
||||
|
||||
// Skill nodes - Technical (8 skills)
|
||||
{
|
||||
id: 'data-analysis',
|
||||
type: 'skill',
|
||||
label: 'Data Analysis',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
type: 'skill',
|
||||
label: 'Python',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
type: 'skill',
|
||||
label: 'SQL',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'power-bi',
|
||||
type: 'skill',
|
||||
label: 'Power BI',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'javascript-typescript',
|
||||
type: 'skill',
|
||||
label: 'JavaScript / TypeScript',
|
||||
shortLabel: 'JS/TS',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'excel',
|
||||
type: 'skill',
|
||||
label: 'Excel',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'algorithm-design',
|
||||
type: 'skill',
|
||||
label: 'Algorithm Design',
|
||||
shortLabel: 'Algorithms',
|
||||
domain: 'technical',
|
||||
},
|
||||
{
|
||||
id: 'data-pipelines',
|
||||
type: 'skill',
|
||||
label: 'Data Pipelines',
|
||||
shortLabel: 'Pipelines',
|
||||
domain: 'technical',
|
||||
},
|
||||
|
||||
// Skill nodes - Healthcare Domain (6 skills)
|
||||
{
|
||||
id: 'medicines-optimisation',
|
||||
type: 'skill',
|
||||
label: 'Medicines Optimisation',
|
||||
shortLabel: 'Med Opt',
|
||||
domain: 'clinical',
|
||||
},
|
||||
{
|
||||
id: 'population-health',
|
||||
type: 'skill',
|
||||
label: 'Population Health',
|
||||
shortLabel: 'Pop Health',
|
||||
domain: 'clinical',
|
||||
},
|
||||
{
|
||||
id: 'nice-ta',
|
||||
type: 'skill',
|
||||
label: 'NICE TA Implementation',
|
||||
shortLabel: 'NICE TA',
|
||||
domain: 'clinical',
|
||||
},
|
||||
{
|
||||
id: 'health-economics',
|
||||
type: 'skill',
|
||||
label: 'Health Economics',
|
||||
shortLabel: 'Health Econ',
|
||||
domain: 'clinical',
|
||||
},
|
||||
{
|
||||
id: 'clinical-pathways',
|
||||
type: 'skill',
|
||||
label: 'Clinical Pathways',
|
||||
shortLabel: 'Pathways',
|
||||
domain: 'clinical',
|
||||
},
|
||||
{
|
||||
id: 'controlled-drugs',
|
||||
type: 'skill',
|
||||
label: 'Controlled Drugs',
|
||||
shortLabel: 'CD',
|
||||
domain: 'clinical',
|
||||
},
|
||||
|
||||
// Skill nodes - Strategic & Leadership (7 skills)
|
||||
{
|
||||
id: 'budget-management',
|
||||
type: 'skill',
|
||||
label: 'Budget Management',
|
||||
shortLabel: 'Budget',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'stakeholder-engagement',
|
||||
type: 'skill',
|
||||
label: 'Stakeholder Engagement',
|
||||
shortLabel: 'Stakeholders',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'pharma-negotiation',
|
||||
type: 'skill',
|
||||
label: 'Pharmaceutical Negotiation',
|
||||
shortLabel: 'Negotiation',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'team-development',
|
||||
type: 'skill',
|
||||
label: 'Team Development',
|
||||
shortLabel: 'Team Dev',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'change-management',
|
||||
type: 'skill',
|
||||
label: 'Change Management',
|
||||
shortLabel: 'Change Mgmt',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'financial-modelling',
|
||||
type: 'skill',
|
||||
label: 'Financial Modelling',
|
||||
shortLabel: 'Fin Model',
|
||||
domain: 'leadership',
|
||||
},
|
||||
{
|
||||
id: 'executive-comms',
|
||||
type: 'skill',
|
||||
label: 'Executive Communication',
|
||||
shortLabel: 'Exec Comms',
|
||||
domain: 'leadership',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Constellation links connecting skills to roles.
|
||||
* Strength values (0-1) indicate how central that skill was to the role.
|
||||
*/
|
||||
export const constellationLinks: ConstellationLink[] = [
|
||||
// Duty Pharmacist 2016 → Skills (foundation role)
|
||||
{ source: 'duty-pharmacist-2016', target: 'medicines-optimisation', strength: 0.9 },
|
||||
{ source: 'duty-pharmacist-2016', target: 'team-development', strength: 0.6 },
|
||||
{ source: 'duty-pharmacist-2016', target: 'excel', strength: 0.5 },
|
||||
|
||||
// Pharmacy Manager 2017 → Skills (broad operational role)
|
||||
{ source: 'pharmacy-manager-2017', target: 'medicines-optimisation', strength: 0.9 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'team-development', strength: 0.8 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'data-analysis', strength: 0.7 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'excel', strength: 0.7 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'change-management', strength: 0.6 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'budget-management', strength: 0.5 },
|
||||
{ source: 'pharmacy-manager-2017', target: 'stakeholder-engagement', strength: 0.6 },
|
||||
|
||||
// High-Cost Drugs 2022 → Skills (technical + clinical pathway role)
|
||||
{ source: 'high-cost-drugs-2022', target: 'medicines-optimisation', strength: 0.8 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'nice-ta', strength: 0.9 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'clinical-pathways', strength: 0.9 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'health-economics', strength: 0.7 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'python', strength: 0.8 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'data-analysis', strength: 0.8 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'sql', strength: 0.7 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'algorithm-design', strength: 0.6 },
|
||||
{ source: 'high-cost-drugs-2022', target: 'stakeholder-engagement', strength: 0.7 },
|
||||
|
||||
// Deputy Head 2024 → Skills (strategic + analytical leadership)
|
||||
{ source: 'deputy-head-2024', target: 'population-health', strength: 0.95 },
|
||||
{ source: 'deputy-head-2024', target: 'medicines-optimisation', strength: 0.9 },
|
||||
{ source: 'deputy-head-2024', target: 'data-analysis', strength: 0.95 },
|
||||
{ source: 'deputy-head-2024', target: 'python', strength: 0.9 },
|
||||
{ source: 'deputy-head-2024', target: 'sql', strength: 0.9 },
|
||||
{ source: 'deputy-head-2024', target: 'power-bi', strength: 0.8 },
|
||||
{ source: 'deputy-head-2024', target: 'controlled-drugs', strength: 0.7 },
|
||||
{ source: 'deputy-head-2024', target: 'budget-management', strength: 0.9 },
|
||||
{ source: 'deputy-head-2024', target: 'financial-modelling', strength: 0.8 },
|
||||
{ source: 'deputy-head-2024', target: 'pharma-negotiation', strength: 0.7 },
|
||||
{ source: 'deputy-head-2024', target: 'stakeholder-engagement', strength: 0.9 },
|
||||
{ source: 'deputy-head-2024', target: 'team-development', strength: 0.8 },
|
||||
{ source: 'deputy-head-2024', target: 'executive-comms', strength: 0.85 },
|
||||
|
||||
// Interim Head 2025 → Skills (peak analytical + strategic delivery)
|
||||
{ source: 'interim-head-2025', target: 'population-health', strength: 0.95 },
|
||||
{ source: 'interim-head-2025', target: 'medicines-optimisation', strength: 0.9 },
|
||||
{ source: 'interim-head-2025', target: 'data-analysis', strength: 1.0 },
|
||||
{ source: 'interim-head-2025', target: 'python', strength: 0.95 },
|
||||
{ source: 'interim-head-2025', target: 'sql', strength: 0.95 },
|
||||
{ source: 'interim-head-2025', target: 'algorithm-design', strength: 0.9 },
|
||||
{ source: 'interim-head-2025', target: 'data-pipelines', strength: 0.8 },
|
||||
{ source: 'interim-head-2025', target: 'budget-management', strength: 0.9 },
|
||||
{ source: 'interim-head-2025', target: 'financial-modelling', strength: 0.85 },
|
||||
{ source: 'interim-head-2025', target: 'stakeholder-engagement', strength: 0.9 },
|
||||
{ source: 'interim-head-2025', target: 'executive-comms', strength: 0.9 },
|
||||
{ source: 'interim-head-2025', target: 'change-management', strength: 0.7 },
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EducationExtra } from '@/types/pmr'
|
||||
|
||||
export const educationExtras: EducationExtra[] = [
|
||||
{
|
||||
documentId: 'doc-mpharm',
|
||||
extracurriculars: [
|
||||
'President of UEA Pharmacy Society',
|
||||
'Secretary & Vice-President of UEA Ultimate Frisbee',
|
||||
'Publicity Officer for UEA Alzheimer\'s Society',
|
||||
],
|
||||
researchDescription: 'Final year research project investigating cocrystal formation for improved drug delivery properties. Awarded Distinction grade (75.1%).',
|
||||
osceScore: '80%',
|
||||
},
|
||||
{
|
||||
documentId: 'doc-mary-seacole',
|
||||
programmeDetail: 'Formal NHS leadership qualification providing theoretical grounding in healthcare leadership approaches, change management, and system-level thinking.',
|
||||
},
|
||||
]
|
||||
+50
-6
@@ -8,6 +8,17 @@ export const kpis: KPI[] = [
|
||||
sub: 'NHS prescribing',
|
||||
colorVariant: 'green',
|
||||
explanation: 'Managed the ICB\'s total prescribing budget with sophisticated forecasting models identifying cost pressures and enabling proactive financial planning across Norfolk & Waveney.',
|
||||
story: {
|
||||
context: 'Total prescribing budget for NHS Norfolk & Waveney ICB, covering primary care prescriptions for a population of 1.2 million across the integrated care system.',
|
||||
role: 'Managed with sophisticated forecasting models, identifying cost pressures and enabling proactive financial planning. Full analytical accountability to ICB board for budget oversight and variance analysis.',
|
||||
outcomes: [
|
||||
'Sophisticated forecasting models identifying cost pressures ahead of time',
|
||||
'Proactive financial planning enabled across the system',
|
||||
'Interactive dashboard tracking expenditure patterns in real-time',
|
||||
'Monthly variance analysis and financial reporting to executive team',
|
||||
],
|
||||
period: 'Jul 2024 — Present',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'savings',
|
||||
@@ -16,6 +27,17 @@ export const kpis: KPI[] = [
|
||||
sub: 'Identified & tracked',
|
||||
colorVariant: 'amber',
|
||||
explanation: 'Identified and prioritised a £14.6M efficiency programme through comprehensive data analysis; achieved over-target performance through targeted, evidence-based interventions across the integrated care system.',
|
||||
story: {
|
||||
context: 'System-wide efficiency programme identified through comprehensive analysis of real-world prescribing data, targeting high-cost medicines with cost-effective alternatives and evidence-based switching opportunities.',
|
||||
role: 'Led data analysis to identify, prioritise, and track the efficiency programme. Built automated analysis tools to compress months of manual work into days, enabling targeted interventions across the integrated care system.',
|
||||
outcomes: [
|
||||
'Identified £14.6M efficiency programme through automated data analysis',
|
||||
'Achieved over-target performance by October 2025',
|
||||
'Built Python switching algorithm identifying 14,000 patients and £2.6M savings',
|
||||
'Automated incentive scheme analysis with novel GP payment system',
|
||||
],
|
||||
period: 'May 2025 — Nov 2025',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'years',
|
||||
@@ -24,13 +46,35 @@ export const kpis: KPI[] = [
|
||||
sub: 'Since 2016',
|
||||
colorVariant: 'teal',
|
||||
explanation: 'Continuous NHS service since August 2016, progressing from community pharmacy through prescribing data analysis to system-level population health data leadership.',
|
||||
story: {
|
||||
context: 'Career journey spanning community pharmacy, hospital interface, and system-level population health analytics across NHS Norfolk & Waveney, demonstrating continuous progression and expanding scope of impact.',
|
||||
role: 'Progressed from frontline community pharmacy through prescribing data analysis roles to system-level population health leadership, consistently taking on greater analytical and strategic responsibility across the integrated care system.',
|
||||
outcomes: [
|
||||
'Community pharmacy foundation: patient care and medicines optimisation (2016-2022)',
|
||||
'High-cost drugs and interface: NICE implementation and pathway development (2022-2024)',
|
||||
'Population health leadership: data-driven decision making at system scale (2024-present)',
|
||||
'Self-taught Python, SQL, and analytics to solve complex problems at scale',
|
||||
],
|
||||
period: 'Aug 2016 — Present',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
value: '12',
|
||||
label: 'Team Size Led',
|
||||
sub: 'Cross-functional',
|
||||
colorVariant: 'green',
|
||||
explanation: 'Led a cross-functional team of 12 spanning data analysts, population health specialists, and pharmacists across data, analytics, and population health workstreams.',
|
||||
id: 'population',
|
||||
value: '1.2M',
|
||||
label: 'Population Served',
|
||||
sub: 'Norfolk & Waveney ICS',
|
||||
colorVariant: 'teal',
|
||||
explanation: 'Leading population health analytics and data-driven medicines optimisation for Norfolk & Waveney Integrated Care System, covering 1.2 million people across the region.',
|
||||
story: {
|
||||
context: 'Norfolk & Waveney Integrated Care System serves a population of 1.2 million people across Norfolk and parts of Suffolk, with responsibility for coordinating health and care services across primary care, secondary care, and community services.',
|
||||
role: 'Lead population health analytics, developing patient-level datasets and analytical frameworks from real-world GP prescribing data to identify efficiency opportunities, address health inequalities, and support data-driven decision making at system scale.',
|
||||
outcomes: [
|
||||
'Transformed analytics from practice-level to patient-level SQL analysis',
|
||||
'Built comprehensive medicines data table integrating all dm+d products',
|
||||
'Developed population-scale controlled drug monitoring system',
|
||||
'Created self-serve analytical tools enabling wider team data fluency',
|
||||
],
|
||||
period: 'Jul 2024 — Present',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const personalStatement = `Healthcare leader combining clinical pharmacy expertise with proficiency in Python, SQL, and data analytics, self-taught over the past decade through a drive to find root causes in data and build the most efficient solutions to complex problems. Currently leading population health analytics for NHS Norfolk & Waveney ICB, serving a population of 1.2 million. Experienced in working with messy, real-world prescribing data at scale to deliver actionable insights—from financial scenario modelling and pharmaceutical rebate negotiation to algorithm design and population-level pathway development. Proven track record of identifying and prioritising efficiency programmes worth £14.6M+ through automated, data-driven analysis. Skilled at translating complex clinical, financial, and analytical requirements into clear recommendations for executive stakeholders.`
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SkillMedication } from '@/types/pmr'
|
||||
|
||||
export const skills: SkillMedication[] = [
|
||||
// Technical (8 skills)
|
||||
{
|
||||
id: 'data-analysis',
|
||||
name: 'Data Analysis',
|
||||
@@ -56,4 +57,184 @@ export const skills: SkillMedication[] = [
|
||||
status: 'Active',
|
||||
icon: 'FileCode2',
|
||||
},
|
||||
{
|
||||
id: 'excel',
|
||||
name: 'Excel',
|
||||
frequency: 'Daily',
|
||||
startYear: 2016,
|
||||
yearsOfExperience: 9,
|
||||
proficiency: 85,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Sheet',
|
||||
},
|
||||
{
|
||||
id: 'algorithm-design',
|
||||
name: 'Algorithm Design',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 82,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'GitBranch',
|
||||
},
|
||||
{
|
||||
id: 'data-pipelines',
|
||||
name: 'Data Pipelines',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2023,
|
||||
yearsOfExperience: 2,
|
||||
proficiency: 75,
|
||||
category: 'Technical',
|
||||
status: 'Active',
|
||||
icon: 'Workflow',
|
||||
},
|
||||
|
||||
// Healthcare Domain (6 skills)
|
||||
{
|
||||
id: 'medicines-optimisation',
|
||||
name: 'Medicines Optimisation',
|
||||
frequency: 'Twice daily',
|
||||
startYear: 2016,
|
||||
yearsOfExperience: 9,
|
||||
proficiency: 95,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Pill',
|
||||
},
|
||||
{
|
||||
id: 'population-health',
|
||||
name: 'Population Health',
|
||||
frequency: 'Daily',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 90,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Users',
|
||||
},
|
||||
{
|
||||
id: 'nice-ta',
|
||||
name: 'NICE TA Implementation',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 92,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'FileCheck',
|
||||
},
|
||||
{
|
||||
id: 'health-economics',
|
||||
name: 'Health Economics',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 80,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'TrendingUp',
|
||||
},
|
||||
{
|
||||
id: 'clinical-pathways',
|
||||
name: 'Clinical Pathways',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 88,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'Route',
|
||||
},
|
||||
{
|
||||
id: 'controlled-drugs',
|
||||
name: 'Controlled Drugs',
|
||||
frequency: 'When required',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 85,
|
||||
category: 'Domain',
|
||||
status: 'Active',
|
||||
icon: 'ShieldAlert',
|
||||
},
|
||||
|
||||
// Strategic & Leadership (7 skills)
|
||||
{
|
||||
id: 'budget-management',
|
||||
name: 'Budget Management',
|
||||
frequency: 'Daily',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 90,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Banknote',
|
||||
},
|
||||
{
|
||||
id: 'stakeholder-engagement',
|
||||
name: 'Stakeholder Engagement',
|
||||
frequency: 'Twice daily',
|
||||
startYear: 2022,
|
||||
yearsOfExperience: 3,
|
||||
proficiency: 88,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Handshake',
|
||||
},
|
||||
{
|
||||
id: 'pharma-negotiation',
|
||||
name: 'Pharmaceutical Negotiation',
|
||||
frequency: 'When required',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 82,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'MessageSquare',
|
||||
},
|
||||
{
|
||||
id: 'team-development',
|
||||
name: 'Team Development',
|
||||
frequency: 'Daily',
|
||||
startYear: 2017,
|
||||
yearsOfExperience: 8,
|
||||
proficiency: 85,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'UserPlus',
|
||||
},
|
||||
{
|
||||
id: 'change-management',
|
||||
name: 'Change Management',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2018,
|
||||
yearsOfExperience: 7,
|
||||
proficiency: 80,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'RefreshCw',
|
||||
},
|
||||
{
|
||||
id: 'financial-modelling',
|
||||
name: 'Financial Modelling',
|
||||
frequency: 'Once weekly',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 78,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Calculator',
|
||||
},
|
||||
{
|
||||
id: 'executive-comms',
|
||||
name: 'Executive Communication',
|
||||
frequency: 'Twice weekly',
|
||||
startYear: 2024,
|
||||
yearsOfExperience: 1,
|
||||
proficiency: 85,
|
||||
category: 'Leadership',
|
||||
status: 'Active',
|
||||
icon: 'Presentation',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const SECTION_IDS = ['about', 'skills', 'experience', 'education', 'projects', 'contact'] as const
|
||||
// Map tile IDs to section IDs for SubNav
|
||||
const sectionTileMap: Record<string, string> = {
|
||||
'patient-summary': 'overview',
|
||||
'core-skills': 'skills',
|
||||
'career-activity': 'experience',
|
||||
'projects': 'projects',
|
||||
'education': 'education',
|
||||
}
|
||||
|
||||
type SectionId = typeof SECTION_IDS[number]
|
||||
/**
|
||||
* Hook to track which section is currently visible using IntersectionObserver.
|
||||
* Observes tiles by their data-tile-id attribute and maps them to section IDs.
|
||||
*
|
||||
* @returns The currently active section ID
|
||||
*/
|
||||
export function useActiveSection(): string {
|
||||
const [activeSection, setActiveSection] = useState<string>('overview')
|
||||
|
||||
export function useActiveSection(): SectionId {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('about')
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const visibleSectionsRef = useRef<Map<string, number>>(new Map())
|
||||
useEffect(() => {
|
||||
// Find all tiles with data-tile-id attribute
|
||||
const tiles = Array.from(
|
||||
document.querySelectorAll('[data-tile-id]')
|
||||
) as HTMLElement[]
|
||||
|
||||
const handleIntersect = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
const sectionId = entry.target.id
|
||||
if (SECTION_IDS.includes(sectionId as SectionId)) {
|
||||
if (entry.isIntersecting) {
|
||||
visibleSectionsRef.current.set(sectionId, entry.intersectionRatio)
|
||||
} else {
|
||||
visibleSectionsRef.current.delete(sectionId)
|
||||
if (tiles.length === 0) return
|
||||
|
||||
// IntersectionObserver to track which tile is visible
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Find the entry with the highest intersection ratio
|
||||
const visibleEntries = entries.filter((entry) => entry.isIntersecting)
|
||||
|
||||
if (visibleEntries.length === 0) return
|
||||
|
||||
// Get the most visible tile (highest intersection ratio)
|
||||
const mostVisible = visibleEntries.reduce((prev, current) =>
|
||||
current.intersectionRatio > prev.intersectionRatio ? current : prev
|
||||
)
|
||||
|
||||
// Get the tile ID and map to section ID
|
||||
const tileId = mostVisible.target.getAttribute('data-tile-id')
|
||||
if (tileId && sectionTileMap[tileId]) {
|
||||
setActiveSection(sectionTileMap[tileId])
|
||||
}
|
||||
},
|
||||
{
|
||||
// Trigger when tile is 25% visible
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
// Use viewport as root, with some margin for better UX
|
||||
rootMargin: '-80px 0px -80% 0px',
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const visibleEntries = Array.from(visibleSectionsRef.current.entries())
|
||||
if (visibleEntries.length > 0) {
|
||||
visibleEntries.sort((a, b) => {
|
||||
const indexA = SECTION_IDS.indexOf(a[0] as SectionId)
|
||||
const indexB = SECTION_IDS.indexOf(b[0] as SectionId)
|
||||
return indexA - indexB
|
||||
})
|
||||
|
||||
const topSection = visibleEntries[0][0] as SectionId
|
||||
setActiveSection(topSection)
|
||||
// Observe all tiles
|
||||
tiles.forEach((tile) => observer.observe(tile))
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
tiles.forEach((tile) => observer.unobserve(tile))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(handleIntersect, {
|
||||
rootMargin: '-20% 0px -70% 0px',
|
||||
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1],
|
||||
})
|
||||
|
||||
SECTION_IDS.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element && observerRef.current) {
|
||||
observerRef.current.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [handleIntersect])
|
||||
|
||||
return activeSection
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
interface BreakpointState {
|
||||
breakpoint: Breakpoint
|
||||
isMobile: boolean
|
||||
isTablet: boolean
|
||||
isDesktop: boolean
|
||||
}
|
||||
|
||||
export function useBreakpoint(): BreakpointState {
|
||||
const [state, setState] = useState<BreakpointState>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||
}
|
||||
const width = window.innerWidth
|
||||
if (width < 768) {
|
||||
return { breakpoint: 'mobile', isMobile: true, isTablet: false, isDesktop: false }
|
||||
}
|
||||
if (width < 1024) {
|
||||
return { breakpoint: 'tablet', isMobile: false, isTablet: true, isDesktop: false }
|
||||
}
|
||||
return { breakpoint: 'desktop', isMobile: false, isTablet: false, isDesktop: true }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth
|
||||
let breakpoint: Breakpoint
|
||||
let isMobile: boolean
|
||||
let isTablet: boolean
|
||||
let isDesktop: boolean
|
||||
|
||||
if (width < 768) {
|
||||
breakpoint = 'mobile'
|
||||
isMobile = true
|
||||
isTablet = false
|
||||
isDesktop = false
|
||||
} else if (width < 1024) {
|
||||
breakpoint = 'tablet'
|
||||
isMobile = false
|
||||
isTablet = true
|
||||
isDesktop = false
|
||||
} else {
|
||||
breakpoint = 'desktop'
|
||||
isMobile = false
|
||||
isTablet = false
|
||||
isDesktop = true
|
||||
}
|
||||
|
||||
setState({ breakpoint, isMobile, isTablet, isDesktop })
|
||||
}
|
||||
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { RefObject, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Focus trap hook for modal dialogs and panels
|
||||
* Traps Tab/Shift+Tab within the container when active
|
||||
* Returns focus to previously focused element when deactivated
|
||||
*/
|
||||
export function useFocusTrap(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
isActive: boolean
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!isActive || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const previousActiveElement = document.activeElement as HTMLElement
|
||||
|
||||
// Get all focusable elements
|
||||
const getFocusableElements = (): HTMLElement[] => {
|
||||
const selectors = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
]
|
||||
|
||||
const elements = container.querySelectorAll<HTMLElement>(
|
||||
selectors.join(', ')
|
||||
)
|
||||
|
||||
return Array.from(elements).filter(
|
||||
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
|
||||
)
|
||||
}
|
||||
|
||||
// Focus first element on mount
|
||||
const focusableElements = getFocusableElements()
|
||||
if (focusableElements.length > 0) {
|
||||
focusableElements[0].focus()
|
||||
}
|
||||
|
||||
// Handle Tab key to trap focus
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return
|
||||
|
||||
const focusable = getFocusableElements()
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const firstElement = focusable[0]
|
||||
const lastElement = focusable[focusable.length - 1]
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift+Tab: moving backwards
|
||||
if (activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement.focus()
|
||||
}
|
||||
} else {
|
||||
// Tab: moving forwards
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
// Cleanup: return focus to previous element
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
if (previousActiveElement && previousActiveElement.focus) {
|
||||
previousActiveElement.focus()
|
||||
}
|
||||
}
|
||||
}, [isActive, containerRef])
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface UseScrollCondensationOptions {
|
||||
threshold?: number
|
||||
scrollContainer?: HTMLElement | null
|
||||
}
|
||||
|
||||
export function useScrollCondensation(options: UseScrollCondensationOptions = {}) {
|
||||
const { threshold = 100, scrollContainer } = options
|
||||
const [isCondensed, setIsCondensed] = useState(false)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollContainer) return
|
||||
setIsCondensed(scrollContainer.scrollTop >= threshold)
|
||||
}, [scrollContainer, threshold])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) return
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true })
|
||||
// Check initial state
|
||||
handleScroll()
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [scrollContainer, handleScroll])
|
||||
|
||||
return { isCondensed }
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
|
||||
interface UseScrollRevealOptions {
|
||||
threshold?: number
|
||||
rootMargin?: string
|
||||
triggerOnce?: boolean
|
||||
}
|
||||
|
||||
export function useScrollReveal<T extends HTMLElement>(
|
||||
options: UseScrollRevealOptions = {}
|
||||
): [RefObject<T>, boolean] {
|
||||
const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options
|
||||
const ref = useRef<T>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
if (triggerOnce) {
|
||||
observer.unobserve(element)
|
||||
}
|
||||
} else if (!triggerOnce) {
|
||||
setIsVisible(false)
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [threshold, rootMargin, triggerOnce])
|
||||
|
||||
return [ref, isVisible]
|
||||
}
|
||||
+87
-71
@@ -124,6 +124,7 @@
|
||||
--border-light: #E4EDEB;
|
||||
--sidebar-width: 272px;
|
||||
--topbar-height: 48px;
|
||||
--subnav-height: 36px;
|
||||
--radius-card: 8px;
|
||||
--radius-sm: 6px;
|
||||
--shadow-sm: 0 1px 2px rgba(26,43,42,0.05);
|
||||
@@ -132,25 +133,12 @@
|
||||
--font-body: var(--font-ui);
|
||||
--font-mono-dashboard: 'Geist Mono', 'Fira Code', monospace;
|
||||
|
||||
/* Legacy PMR tokens — kept for backward compat during transition (cleaned up in Task 21) */
|
||||
--pmr-content: #F0F5F4;
|
||||
--pmr-card: #FFFFFF;
|
||||
--pmr-sidebar: #F7FAFA;
|
||||
--pmr-banner: #334155;
|
||||
--pmr-nhs-blue: #005EB8;
|
||||
--pmr-green: #22C55E;
|
||||
--pmr-amber: #F59E0B;
|
||||
--pmr-red: #EF4444;
|
||||
--pmr-text-primary: #1A2B2A;
|
||||
--pmr-text-secondary: #5B7A78;
|
||||
--pmr-border: #D4E0DE;
|
||||
--pmr-border-dark: #D1D5DB;
|
||||
--pmr-selected: #EFF6FF;
|
||||
--pmr-alert-bg: #FEF3C7;
|
||||
--pmr-alert-border: #F59E0B;
|
||||
--pmr-alert-text: #92400E;
|
||||
--pmr-radius: 8px;
|
||||
--pmr-radius-login: 12px;
|
||||
/* Detail panel */
|
||||
--panel-narrow: 400px;
|
||||
--panel-wide: 60vw;
|
||||
--backdrop-blur: 4px;
|
||||
--backdrop-bg: rgba(26,43,42,0.15);
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -186,11 +174,6 @@ body {
|
||||
.font-geist-mono {
|
||||
font-family: var(--font-geist-mono);
|
||||
}
|
||||
.pmr-theme {
|
||||
background-color: var(--bg-dashboard);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
@@ -243,6 +226,15 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* Login spinner */
|
||||
@keyframes login-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.login-spinner {
|
||||
animation: login-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for sidebar */
|
||||
.pmr-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
@@ -266,6 +258,11 @@ html {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* SubNav horizontal scroll — hide scrollbar */
|
||||
.subnav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Dashboard card grid responsive — mobile-first */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
@@ -288,53 +285,6 @@ html {
|
||||
}
|
||||
}
|
||||
|
||||
/* KPI flip cards */
|
||||
.metric-card {
|
||||
perspective: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metric-card-inner {
|
||||
transition: transform 0.4s ease-in-out;
|
||||
transform-style: preserve-3d;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.metric-card-inner.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.metric-card-front,
|
||||
.metric-card-back {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.metric-card-back {
|
||||
transform: rotateY(180deg);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metric-card-inner {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.metric-card-inner.flipped .metric-card-front {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.metric-card-inner .metric-card-back {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.metric-card-inner.flipped .metric-card-back {
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Activity grid responsive — mobile-first (used in CareerActivityTile) */
|
||||
.activity-grid {
|
||||
@@ -400,10 +350,76 @@ textarea:focus-visible {
|
||||
outline-offset: 0px;
|
||||
}
|
||||
|
||||
/* ===== DETAIL PANEL ANIMATIONS ===== */
|
||||
@keyframes panel-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes panel-slide-out {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes backdrop-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Detail panel responsive widths */
|
||||
.detail-panel[data-width="narrow"] {
|
||||
width: var(--panel-narrow);
|
||||
}
|
||||
|
||||
.detail-panel[data-width="wide"] {
|
||||
width: var(--panel-wide);
|
||||
}
|
||||
|
||||
/* Mobile: both narrow and wide become full-width */
|
||||
@media (max-width: 767px) {
|
||||
.detail-panel[data-width="narrow"],
|
||||
.detail-panel[data-width="wide"] {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable pulse animation on status badge dot */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Instant panel animations */
|
||||
@keyframes panel-slide-in {
|
||||
from { transform: none; }
|
||||
to { transform: none; }
|
||||
}
|
||||
|
||||
@keyframes panel-slide-out {
|
||||
from { transform: none; }
|
||||
to { transform: none; }
|
||||
}
|
||||
|
||||
@keyframes backdrop-fade-in {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Static login spinner indicator */
|
||||
.login-spinner {
|
||||
animation: none;
|
||||
border-top-color: #0D6E6E;
|
||||
}
|
||||
|
||||
/* Instant SubNav transitions */
|
||||
.subnav-scroll button {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Instant smooth scroll override */
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+24
-19
@@ -7,6 +7,8 @@ import { problems } from '@/data/problems'
|
||||
import { investigations } from '@/data/investigations'
|
||||
import { documents } from '@/data/documents'
|
||||
import { skills } from '@/data/skills'
|
||||
import { kpis } from '@/data/kpis'
|
||||
import type { DetailPanelContent } from '@/types/pmr'
|
||||
|
||||
export type PaletteSection = 'Experience' | 'Core Skills' | 'Active Projects' | 'Achievements' | 'Education' | 'Quick Actions'
|
||||
|
||||
@@ -15,6 +17,7 @@ export type PaletteAction =
|
||||
| { type: 'expand'; tileId: string; itemId: string }
|
||||
| { type: 'link'; url: string }
|
||||
| { type: 'download' }
|
||||
| { type: 'panel'; panelContent: DetailPanelContent }
|
||||
|
||||
export type IconColorVariant = 'teal' | 'green' | 'amber' | 'purple'
|
||||
|
||||
@@ -74,25 +77,17 @@ export function buildPaletteData(): PaletteItem[] {
|
||||
})
|
||||
})
|
||||
|
||||
// Core Skills — from skills.ts, matching concept format with proficiency %
|
||||
const skillDescriptions: Record<string, string> = {
|
||||
'Data Analysis': 'Primary expertise \u00b7 NHS population data',
|
||||
'Python': 'Data pipelines, automation, analytics',
|
||||
'SQL': 'Advanced queries, database migration',
|
||||
'Power BI': 'Dashboard design & deployment',
|
||||
'JavaScript / TypeScript': 'Web development & tooling',
|
||||
}
|
||||
|
||||
// Core Skills — all ~21 skills from skills.ts, opening detail panel on select
|
||||
skills.forEach((skill) => {
|
||||
items.push({
|
||||
id: `skill-${skill.id}`,
|
||||
title: `${skill.name} \u2014 ${skill.proficiency}%`,
|
||||
subtitle: skillDescriptions[skill.name] ?? `${skill.frequency} \u00b7 Since ${skill.startYear}`,
|
||||
subtitle: `${skill.frequency} \u00b7 Since ${skill.startYear} \u00b7 ${skill.category}`,
|
||||
section: 'Core Skills',
|
||||
iconVariant: 'green',
|
||||
iconType: 'skill',
|
||||
keywords: `${skill.name.toLowerCase()} ${skill.proficiency} ${skill.frequency.toLowerCase()}`,
|
||||
action: { type: 'expand', tileId: 'core-skills', itemId: skill.id },
|
||||
keywords: `${skill.name.toLowerCase()} ${skill.proficiency} ${skill.frequency.toLowerCase()} ${skill.category.toLowerCase()}`,
|
||||
action: { type: 'panel', panelContent: { type: 'skill', skill } },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +114,7 @@ export function buildPaletteData(): PaletteItem[] {
|
||||
]
|
||||
|
||||
projectEntries.forEach((entry) => {
|
||||
const investigation = investigations.find(inv => inv.id === entry.investigationId)
|
||||
items.push({
|
||||
id: `proj-${entry.investigationId}`,
|
||||
title: entry.name,
|
||||
@@ -127,35 +123,42 @@ export function buildPaletteData(): PaletteItem[] {
|
||||
iconVariant: 'amber',
|
||||
iconType: 'project',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'expand', tileId: 'projects', itemId: entry.investigationId },
|
||||
action: investigation
|
||||
? { type: 'panel', panelContent: { type: 'project', investigation } }
|
||||
: { type: 'scroll', tileId: 'projects' },
|
||||
})
|
||||
})
|
||||
|
||||
// Achievements — matching concept HTML entries
|
||||
const achievementEntries: Array<{ title: string; sub: string; keywords: string }> = [
|
||||
// Achievements — open corresponding KPI detail panel
|
||||
const achievementEntries: Array<{ title: string; sub: string; keywords: string; kpiId: string }> = [
|
||||
{
|
||||
title: '\u00a314.6M Efficiency Savings Identified',
|
||||
sub: 'Data-driven prescribing interventions',
|
||||
keywords: '14.6m efficiency savings identified data-driven prescribing interventions money cost',
|
||||
kpiId: 'savings',
|
||||
},
|
||||
{
|
||||
title: '\u00a3220M Budget Oversight',
|
||||
sub: 'Full analytical accountability to ICB board',
|
||||
keywords: '220m budget oversight analytical accountability icb board',
|
||||
kpiId: 'budget',
|
||||
},
|
||||
{
|
||||
title: 'Power BI Dashboards for 200+ Users',
|
||||
sub: 'Clinicians & commissioners across ICB',
|
||||
keywords: 'power bi dashboards 200 users clinicians commissioners',
|
||||
kpiId: 'years',
|
||||
},
|
||||
{
|
||||
title: 'Team of 12 Led',
|
||||
sub: 'Cross-functional data & population health',
|
||||
keywords: 'team 12 led cross-functional data population health leadership management',
|
||||
title: '1.2M Population Served',
|
||||
sub: 'Norfolk & Waveney Integrated Care System',
|
||||
keywords: '1.2m population served norfolk waveney ics integrated care system',
|
||||
kpiId: 'population',
|
||||
},
|
||||
]
|
||||
|
||||
achievementEntries.forEach((entry, i) => {
|
||||
const kpi = kpis.find(k => k.id === entry.kpiId)
|
||||
items.push({
|
||||
id: `ach-${i}`,
|
||||
title: entry.title,
|
||||
@@ -164,7 +167,9 @@ export function buildPaletteData(): PaletteItem[] {
|
||||
iconVariant: 'amber',
|
||||
iconType: 'achievement',
|
||||
keywords: entry.keywords,
|
||||
action: { type: 'scroll', tileId: 'latest-results' },
|
||||
action: kpi
|
||||
? { type: 'panel', panelContent: { type: 'kpi', kpi } }
|
||||
: { type: 'scroll', tileId: 'latest-results' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface KPI {
|
||||
sub: string
|
||||
colorVariant: 'green' | 'amber' | 'teal'
|
||||
explanation: string
|
||||
story?: KPIStory // NEW: rich detail for panel
|
||||
}
|
||||
|
||||
export interface SkillMedication {
|
||||
@@ -145,3 +146,52 @@ export interface SkillMedication {
|
||||
status: 'Active' | 'Historical'
|
||||
icon: string
|
||||
}
|
||||
|
||||
// Skill categories for grouped display
|
||||
export type SkillCategory = 'Technical' | 'Domain' | 'Leadership'
|
||||
|
||||
// Extended KPI with story content for detail panel
|
||||
export interface KPIStory {
|
||||
context: string // What this number covers
|
||||
role: string // Your role / what you did
|
||||
outcomes: string[] // Key decisions or results
|
||||
period?: string // Time period
|
||||
}
|
||||
|
||||
// Constellation-specific types
|
||||
export interface ConstellationNode {
|
||||
id: string
|
||||
type: 'role' | 'skill'
|
||||
label: string
|
||||
shortLabel?: string // abbreviated for small nodes
|
||||
organization?: string
|
||||
startYear?: number
|
||||
endYear?: number | null
|
||||
orgColor?: string
|
||||
domain?: 'clinical' | 'technical' | 'leadership'
|
||||
}
|
||||
|
||||
export interface ConstellationLink {
|
||||
source: string
|
||||
target: string
|
||||
strength: number
|
||||
}
|
||||
|
||||
// Detail panel content union
|
||||
export type DetailPanelContent =
|
||||
| { type: 'kpi'; kpi: KPI }
|
||||
| { type: 'skill'; skill: SkillMedication }
|
||||
| { type: 'skills-all'; category?: SkillCategory }
|
||||
| { type: 'consultation'; consultation: Consultation }
|
||||
| { type: 'project'; investigation: Investigation }
|
||||
| { type: 'education'; document: Document }
|
||||
| { type: 'career-role'; consultation: Consultation }
|
||||
|
||||
// Education extras (for detail panel)
|
||||
export interface EducationExtra {
|
||||
documentId: string
|
||||
extracurriculars?: string[]
|
||||
researchDescription?: string
|
||||
programmeDetail?: string
|
||||
osceScore?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user