feat(ui): multi-select projects, grouped grid, refresh, slack warnings

ProjectPicker becomes a searchable multi-select (chips + checklist). App renders
one ProjectGroup per selected project (a Person on several appears in each), with
per-group member/photo counts. Surface Slack status (degraded/unconfigured) and a
Refresh-avatars button that re-sweeps the directory and reloads groups.
This commit is contained in:
Julien Calixte
2026-06-26 16:25:08 +01:00
parent 09238df7e3
commit 30b96ecf64
5 changed files with 242 additions and 54 deletions

View File

@@ -1,50 +1,88 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue"
import type { DataSource, Member, Project } from "@/types"
import { fetchMembers, fetchProjects } from "@/api"
import { computed, onMounted, ref, watch } from "vue"
import type { DataSource, Member, Project, SlackStatus } from "@/types"
import { fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
import ProjectPicker from "@/components/ProjectPicker.vue"
import MemberGrid from "@/components/MemberGrid.vue"
import ProjectGroup from "@/components/ProjectGroup.vue"
interface GroupState {
project: Project
members: Member[]
loading: boolean
error: string | null
}
const projects = ref<Project[]>([])
const selectedProjectId = ref<string>("")
const members = ref<Member[]>([])
const selectedIds = ref<string[]>([])
const groups = ref<GroupState[]>([])
const source = ref<DataSource>("napta")
const slackStatus = ref<SlackStatus | null>(null)
const projectsError = ref<string | null>(null)
const membersError = ref<string | null>(null)
const loadingMembers = ref(false)
const refreshing = ref(false)
function errMsg(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
const projectById = computed(() => {
const m = new Map<string, Project>()
for (const p of projects.value) m.set(p.id, p)
return m
})
onMounted(async () => {
try {
const res = await fetchProjects()
projects.value = res.projects
source.value = res.source
if (res.projects.length > 0) {
selectedProjectId.value = res.projects[0].id
}
} catch (err) {
projectsError.value = err instanceof Error ? err.message : String(err)
projectsError.value = errMsg(err)
}
})
watch(selectedProjectId, async (id) => {
if (!id) {
members.value = []
return
}
loadingMembers.value = true
membersError.value = null
// Reassign the array element by id so reactivity fires reliably.
function setGroup(projectId: string, patch: Partial<GroupState>) {
const i = groups.value.findIndex((g) => g.project.id === projectId)
if (i >= 0) groups.value[i] = { ...groups.value[i], ...patch }
}
async function loadGroup(projectId: string) {
setGroup(projectId, { loading: true, error: null })
try {
const res = await fetchMembers(id)
members.value = res.members
const res = await fetchMembers(projectId)
setGroup(projectId, { members: res.members, loading: false })
slackStatus.value = res.slack
source.value = res.source
} catch (err) {
membersError.value = err instanceof Error ? err.message : String(err)
members.value = []
} finally {
loadingMembers.value = false
setGroup(projectId, { members: [], error: errMsg(err), loading: false })
}
}
// Reconcile the open Project Groups with the current Selection.
watch(selectedIds, (ids) => {
groups.value = groups.value.filter((g) => ids.includes(g.project.id))
for (const id of ids) {
if (groups.value.some((g) => g.project.id === id)) continue
const project = projectById.value.get(id)
if (!project) continue
groups.value.push({ project, members: [], loading: true, error: null })
loadGroup(id)
}
groups.value.sort((a, b) => ids.indexOf(a.project.id) - ids.indexOf(b.project.id))
})
async function refreshAvatars() {
refreshing.value = true
try {
await refreshSlackDirectory()
await Promise.all(groups.value.map((g) => loadGroup(g.project.id)))
} catch (err) {
slackStatus.value = "degraded"
console.error(err)
} finally {
refreshing.value = false
}
}
</script>
<template>
@@ -54,20 +92,46 @@ watch(selectedProjectId, async (id) => {
<img src="/favicon.svg" alt="" class="size-7" />
<span class="text-xl font-semibold">photofetch</span>
</div>
<div class="flex-none">
<div class="flex flex-none items-center gap-2">
<button
v-if="groups.length > 0"
type="button"
class="btn btn-ghost btn-sm"
:disabled="refreshing"
title="Re-sweep the Slack directory"
@click="refreshAvatars"
>
<span v-if="refreshing" class="loading loading-spinner loading-xs"></span>
Refresh avatars
</button>
<ProjectPicker
v-model="selectedProjectId"
v-model="selectedIds"
:projects="projects"
:disabled="projects.length === 0"
/>
</div>
</header>
<main class="mx-auto max-w-6xl p-4 sm:p-6">
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning mb-4">
<main class="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning">
<span>
Showing demo data. Set <code>NAPTA_API_TOKEN</code> and <code>SLACK_BOT_TOKEN</code> on
the server to load your real projects.
Showing demo data. Set <code>NAPTA_CLIENT_ID</code> /
<code>NAPTA_CLIENT_SECRET</code> (and <code>SLACK_BOT_TOKEN</code>) on the server to load
real projects.
</span>
</div>
<div v-if="slackStatus === 'degraded'" role="alert" class="alert alert-warning">
<span>Couldn't reach Slack — showing names without up-to-date photos. Try Refresh.</span>
</div>
<div
v-else-if="slackStatus === 'unconfigured' && source === 'napta'"
role="alert"
class="alert alert-info"
>
<span>
Slack isn't configured, so Members show initials. Set
<code>SLACK_BOT_TOKEN</code> to load photos.
</span>
</div>
@@ -75,12 +139,17 @@ watch(selectedProjectId, async (id) => {
<span>Could not load projects: {{ projectsError }}</span>
</div>
<MemberGrid
v-else
:members="members"
:loading="loadingMembers"
:error="membersError"
:project-selected="!!selectedProjectId"
<div v-else-if="groups.length === 0" class="py-20 text-center opacity-60">
<p>Pick one or more projects to see their teams.</p>
</div>
<ProjectGroup
v-for="g in groups"
:key="g.project.id"
:project="g.project"
:members="g.members"
:loading="g.loading"
:error="g.error"
/>
</main>
</div>

View File

@@ -16,3 +16,12 @@ export function fetchProjects(): Promise<ProjectsResponse> {
export function fetchMembers(projectId: string): Promise<MembersResponse> {
return getJson<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`)
}
export async function refreshSlackDirectory(): Promise<{ refreshed: number }> {
const res = await fetch("/api/slack/refresh", { method: "POST" })
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status} ${res.statusText}${body ? `: ${body}` : ""}`)
}
return (await res.json()) as { refreshed: number }
}

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from "vue"
import type { Member, Project } from "@/types"
import MemberGrid from "@/components/MemberGrid.vue"
const props = defineProps<{
project: Project
members: Member[]
loading: boolean
error: string | null
}>()
const matchedCount = computed(() => props.members.filter((m) => m.slackMatched).length)
</script>
<template>
<section>
<div class="mb-3 flex items-baseline justify-between gap-3">
<h2 class="text-lg font-semibold">
{{ project.name }}
<span v-if="project.clientName" class="font-normal opacity-60">
· {{ project.clientName }}
</span>
</h2>
<span v-if="!loading && !error" class="shrink-0 text-sm opacity-60">
{{ members.length }} {{ members.length === 1 ? "person" : "people" }}
<template v-if="members.length"> · {{ matchedCount }} with photos</template>
</span>
</div>
<MemberGrid :members="members" :loading="loading" :error="error" :project-selected="true" />
</section>
</template>

View File

@@ -1,28 +1,104 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import type { Project } from "@/types"
defineProps<{
const props = defineProps<{
projects: Project[]
modelValue: string
modelValue: string[]
disabled?: boolean
}>()
defineEmits<{
"update:modelValue": [value: string]
const emit = defineEmits<{
"update:modelValue": [value: string[]]
}>()
const search = ref("")
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return props.projects
return props.projects.filter(
(p) => p.name.toLowerCase().includes(q) || (p.clientName ?? "").toLowerCase().includes(q),
)
})
const selectedProjects = computed(() =>
props.modelValue
.map((id) => props.projects.find((p) => p.id === id))
.filter((p): p is Project => Boolean(p)),
)
function isSelected(id: string): boolean {
return props.modelValue.includes(id)
}
function toggle(id: string) {
const next = isSelected(id) ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id]
emit("update:modelValue", next)
}
function remove(id: string) {
emit(
"update:modelValue",
props.modelValue.filter((x) => x !== id),
)
}
</script>
<template>
<select
class="select select-bordered w-72 max-w-full"
:value="modelValue"
:disabled="disabled"
aria-label="Select a project"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
<div class="flex items-center gap-2">
<div v-if="selectedProjects.length" class="hidden max-w-md flex-wrap gap-1 lg:flex">
<span v-for="p in selectedProjects" :key="p.id" class="badge badge-primary gap-1">
{{ p.name }}
<button
class="leading-none"
type="button"
:aria-label="`Remove ${p.name}`"
@click="remove(p.id)"
>
<option v-if="projects.length === 0" value="">No projects available</option>
<option v-for="p in projects" :key="p.id" :value="p.id">
{{ p.name }}<template v-if="p.clientName"> {{ p.clientName }}</template>
</option>
</select>
</button>
</span>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm" :class="{ 'btn-disabled': disabled }">
Projects
<span v-if="modelValue.length" class="badge badge-sm badge-primary">
{{ modelValue.length }}
</span>
</div>
<div
tabindex="0"
class="dropdown-content z-10 mt-2 w-80 rounded-box bg-base-100 p-2 shadow-lg"
>
<input
v-model="search"
type="text"
placeholder="Search projects…"
class="input input-sm input-bordered mb-2 w-full"
aria-label="Search projects"
/>
<ul class="menu max-h-72 flex-nowrap gap-0 overflow-y-auto p-0">
<li v-for="p in filtered" :key="p.id">
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="isSelected(p.id)"
@change="toggle(p.id)"
/>
<span class="flex-1 truncate">
{{ p.name }}
<span v-if="p.clientName" class="opacity-60">· {{ p.clientName }}</span>
</span>
</label>
</li>
<li v-if="filtered.length === 0" class="px-2 py-1 text-sm opacity-60">
No matching projects
</li>
</ul>
</div>
</div>
</div>
</template>

View File

@@ -15,6 +15,7 @@ export interface Member {
}
export type DataSource = "napta" | "fixture"
export type SlackStatus = "ok" | "degraded" | "unconfigured"
export interface ProjectsResponse {
source: DataSource
@@ -23,5 +24,6 @@ export interface ProjectsResponse {
export interface MembersResponse {
source: DataSource
slack: SlackStatus
members: Member[]
}