feature: Design System, App Shell, and Login Redesign
CI / backend (push) Successful in 6m31s
CI / frontend (push) Successful in 58s

This commit is contained in:
2026-08-12 03:37:17 +08:00
parent 6e06717b82
commit 56e100a495
31 changed files with 2987 additions and 217 deletions
@@ -0,0 +1,228 @@
<template>
<div class="flex h-screen min-h-0 bg-canvas text-ink">
<!-- Sidebar -->
<aside
:class="[
'flex flex-col shrink-0 bg-navy text-white transition-[width] duration-200 ease-out',
collapsed ? 'w-16' : 'w-60',
]"
aria-label="Main navigation"
>
<div
:class="[
'flex items-center h-16 border-b border-white/10 shrink-0',
collapsed ? 'justify-center px-2' : 'gap-2.5 px-4',
]"
>
<svg viewBox="0 0 32 32" class="w-7 h-7 shrink-0" aria-hidden="true">
<path fill="#155EEF" d="M16 2 4 6.5v8.2c0 8 5.1 14.6 12 15.3 6.9-.7 12-7.3 12-15.3V6.5z"/>
<path fill="white" d="M16 8.5c-3.6 0-6.6 2.8-6.6 6.3 0 3.4 2.7 6.4 6.6 9.6 3.9-3.2 6.6-6.2 6.6-9.6 0-3.5-3-6.3-6.6-6.3m0 3.4c1.7 0 3.1 1.3 3.1 2.9s-1.4 2.9-3.1 2.9-3.1-1.3-3.1-2.9 1.4-2.9 3.1-2.9"/>
</svg>
<div v-if="!collapsed" class="min-w-0 leading-tight">
<p class="font-bold tracking-tight text-sm truncate">VigilCare</p>
<p class="text-[11px] text-white/60 truncate">Records</p>
</div>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-2 space-y-4">
<div v-if="workspaceItems.length">
<p
v-if="!collapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Workspace
</p>
<ul class="space-y-0.5">
<li v-for="item in workspaceItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:class="navItemClass(item.to)"
>
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
<component :is="item.icon" />
</span>
<span v-if="!collapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
<div v-if="adminItems.length">
<p
v-if="!collapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Administration
</p>
<ul class="space-y-0.5">
<li v-for="item in adminItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:class="navItemClass(item.to)"
>
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
<component :is="item.icon" />
</span>
<span v-if="!collapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
</nav>
<div class="shrink-0 border-t border-white/10 p-2">
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-input px-2.5 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white transition-colors"
:class="collapsed ? 'justify-center' : ''"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
@click="toggleCollapsed"
>
<svg
class="w-5 h-5 shrink-0 transition-transform"
:class="collapsed ? 'rotate-180' : ''"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
<span v-if="!collapsed">Collapse</span>
</button>
</div>
</aside>
<!-- Main column -->
<div class="flex flex-col flex-1 min-w-0 min-h-0">
<header class="flex items-center justify-end gap-4 h-16 shrink-0 px-4 sm:px-6 bg-surface border-b border-line">
<span class="text-sm text-ink-secondary truncate">{{ auth.userFullName }}</span>
<button
type="button"
class="text-sm text-ink-secondary hover:text-ink-strong transition-colors"
@click="auth.logout()"
>
Sign Out
</button>
</header>
<main class="flex-1 min-h-0 overflow-auto">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const COLLAPSE_KEY = 'vigilcare_sidebar_collapsed'
const auth = useAuthStore()
const route = useRoute()
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
watch(collapsed, (value) => {
localStorage.setItem(COLLAPSE_KEY, value ? '1' : '0')
})
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function iconPath(d: string) {
return () =>
h(
'svg',
{ viewBox: '0 0 20 20', fill: 'currentColor', class: 'w-5 h-5' },
[h('path', { 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d })],
)
}
const icons = {
intake: iconPath(
'M3 4.5A1.5 1.5 0 014.5 3h11A1.5 1.5 0 0117 4.5v11a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 013 15.5v-11zM5.5 6a.5.5 0 000 1h9a.5.5 0 000-1h-9zm0 3a.5.5 0 000 1h9a.5.5 0 000-1h-9zm0 3a.5.5 0 000 1h6a.5.5 0 000-1h-6z',
),
cover: iconPath(
'M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm1 3.5A.5.5 0 015.5 6h9a.5.5 0 010 1h-9A.5.5 0 015 6.5zM5.5 9a.5.5 0 000 1h5a.5.5 0 000-1h-5z',
),
entry: iconPath(
'M3.5 3A1.5 1.5 0 002 4.5v11A1.5 1.5 0 003.5 17h13a1.5 1.5 0 001.5-1.5v-11A1.5 1.5 0 0016.5 3h-13zM5 7.5A.5.5 0 015.5 7h9a.5.5 0 010 1h-9A.5.5 0 015 7.5zm0 3A.5.5 0 015.5 10h9a.5.5 0 010 1h-9A.5.5 0 015 10.5zm0 3A.5.5 0 015.5 13h5a.5.5 0 010 1h-5A.5.5 0 015 13.5z',
),
verify: iconPath(
'M10 2a8 8 0 100 16 8 8 0 000-16zm3.53 6.47a.75.75 0 00-1.06-1.06L9 10.88 7.53 9.41a.75.75 0 10-1.06 1.06l2 2a.75.75 0 001.06 0l4-4z',
),
approve: iconPath(
'M10 1.5l7.5 3v5.25c0 4.5-2.9 8.25-7.5 9.25-4.6-1-7.5-4.75-7.5-9.25V4.5L10 1.5zm3.03 6.03a.75.75 0 00-1.06-1.06L9 9.44 7.53 7.97a.75.75 0 10-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z',
),
live: iconPath(
'M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H9.414l-2.707 2.707A1 1 0 015 15.293V13H6a2 2 0 01-2-2V5zm3.5 2.5a.75.75 0 000 1.5h5a.75.75 0 000-1.5h-5z',
),
patients: iconPath(
'M10 2a4 4 0 100 8 4 4 0 000-8zM3.5 16.5a6.5 6.5 0 0113 0 .75.75 0 01-.75.75h-11.5a.75.75 0 01-.75-.75z',
),
dashboard: iconPath(
'M3 4.5A1.5 1.5 0 014.5 3h3A1.5 1.5 0 019 4.5v3A1.5 1.5 0 017.5 9h-3A1.5 1.5 0 013 7.5v-3zM11 4.5A1.5 1.5 0 0112.5 3h3A1.5 1.5 0 0117 4.5v3A1.5 1.5 0 0115.5 9h-3A1.5 1.5 0 0111 7.5v-3zM3 12.5A1.5 1.5 0 014.5 11h3A1.5 1.5 0 019 12.5v3A1.5 1.5 0 017.5 17h-3A1.5 1.5 0 013 15.5v-3zM11 12.5a1.5 1.5 0 011.5-1.5h3a1.5 1.5 0 011.5 1.5v3a1.5 1.5 0 01-1.5 1.5h-3a1.5 1.5 0 01-1.5-1.5v-3z',
),
fhir: iconPath(
'M4.5 3A1.5 1.5 0 003 4.5v11A1.5 1.5 0 004.5 17h11a1.5 1.5 0 001.5-1.5v-11A1.5 1.5 0 0015.5 3h-11zM6 7.25a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 7.25zm0 3a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 10.25zm0 3a.75.75 0 01.75-.75h3.5a.75.75 0 010 1.5h-3.5A.75.75 0 016 13.25z',
),
}
interface NavItem {
label: string
to: string
icon: () => ReturnType<typeof h>
show: boolean
}
const workspaceItems = computed<NavItem[]>(() =>
[
{ label: 'Intake', to: '/intake', icon: icons.intake, show: auth.canIntake },
{ label: 'Cover Sheets', to: '/cover-sheets', icon: icons.cover, show: auth.canIntake },
{ label: 'Data Entry', to: '/entry', icon: icons.entry, show: auth.canEntry },
{ label: 'Verification', to: '/verification', icon: icons.verify, show: auth.canVerify },
{ label: 'Clinical Approval', to: '/approval', icon: icons.approve, show: auth.canApprove },
{ label: 'Live Capture', to: '/live-capture', icon: icons.live, show: auth.canLiveCapture },
{ label: 'Patient History', to: '/patients', icon: icons.patients, show: auth.isAuthenticated },
].filter((item) => item.show),
)
const adminItems = computed<NavItem[]>(() =>
[
{ label: 'Queue Dashboard', to: '/dashboard', icon: icons.dashboard, show: auth.canSupervise },
{ label: 'FHIR Explorer', to: '/fhir-explorer', icon: icons.fhir, show: auth.canSupervise },
].filter((item) => item.show),
)
function isActive(path: string): boolean {
if (path === '/patients') {
return route.path === '/patients' || route.path.startsWith('/patients/')
}
if (path === '/entry') {
return route.path === '/entry' || route.path.startsWith('/entry/')
}
if (path === '/verification') {
return route.path === '/verification' || route.path.startsWith('/verification/')
}
if (path === '/approval') {
return route.path === '/approval' || route.path.startsWith('/approval/')
}
return route.path === path || route.path.startsWith(`${path}/`)
}
function navItemClass(path: string): string {
const base =
'flex items-center gap-2.5 rounded-input text-sm transition-colors ' +
(collapsed.value ? 'justify-center px-2 py-2.5' : 'px-2.5 py-2')
if (isActive(path)) {
return `${base} bg-primary-600 text-white font-medium`
}
return `${base} text-white/75 hover:bg-white/10 hover:text-white`
}
</script>