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:
145
src/App.vue
145
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
32
src/components/ProjectGroup.vue
Normal file
32
src/components/ProjectGroup.vue
Normal 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>
|
||||
@@ -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)"
|
||||
>
|
||||
<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>
|
||||
<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)"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user