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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import type { DataSource, Member, Project } from "@/types"
|
import type { DataSource, Member, Project, SlackStatus } from "@/types"
|
||||||
import { fetchMembers, fetchProjects } from "@/api"
|
import { fetchMembers, fetchProjects, refreshSlackDirectory } from "@/api"
|
||||||
import ProjectPicker from "@/components/ProjectPicker.vue"
|
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 projects = ref<Project[]>([])
|
||||||
const selectedProjectId = ref<string>("")
|
const selectedIds = ref<string[]>([])
|
||||||
const members = ref<Member[]>([])
|
const groups = ref<GroupState[]>([])
|
||||||
const source = ref<DataSource>("napta")
|
const source = ref<DataSource>("napta")
|
||||||
|
const slackStatus = ref<SlackStatus | null>(null)
|
||||||
const projectsError = ref<string | null>(null)
|
const projectsError = ref<string | null>(null)
|
||||||
const membersError = ref<string | null>(null)
|
const refreshing = ref(false)
|
||||||
const loadingMembers = 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetchProjects()
|
const res = await fetchProjects()
|
||||||
projects.value = res.projects
|
projects.value = res.projects
|
||||||
source.value = res.source
|
source.value = res.source
|
||||||
if (res.projects.length > 0) {
|
|
||||||
selectedProjectId.value = res.projects[0].id
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
projectsError.value = err instanceof Error ? err.message : String(err)
|
projectsError.value = errMsg(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedProjectId, async (id) => {
|
// Reassign the array element by id so reactivity fires reliably.
|
||||||
if (!id) {
|
function setGroup(projectId: string, patch: Partial<GroupState>) {
|
||||||
members.value = []
|
const i = groups.value.findIndex((g) => g.project.id === projectId)
|
||||||
return
|
if (i >= 0) groups.value[i] = { ...groups.value[i], ...patch }
|
||||||
}
|
}
|
||||||
loadingMembers.value = true
|
|
||||||
membersError.value = null
|
async function loadGroup(projectId: string) {
|
||||||
|
setGroup(projectId, { loading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const res = await fetchMembers(id)
|
const res = await fetchMembers(projectId)
|
||||||
members.value = res.members
|
setGroup(projectId, { members: res.members, loading: false })
|
||||||
|
slackStatus.value = res.slack
|
||||||
source.value = res.source
|
source.value = res.source
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
membersError.value = err instanceof Error ? err.message : String(err)
|
setGroup(projectId, { members: [], error: errMsg(err), loading: false })
|
||||||
members.value = []
|
|
||||||
} finally {
|
|
||||||
loadingMembers.value = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -54,20 +92,46 @@ watch(selectedProjectId, async (id) => {
|
|||||||
<img src="/favicon.svg" alt="" class="size-7" />
|
<img src="/favicon.svg" alt="" class="size-7" />
|
||||||
<span class="text-xl font-semibold">photofetch</span>
|
<span class="text-xl font-semibold">photofetch</span>
|
||||||
</div>
|
</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
|
<ProjectPicker
|
||||||
v-model="selectedProjectId"
|
v-model="selectedIds"
|
||||||
:projects="projects"
|
:projects="projects"
|
||||||
:disabled="projects.length === 0"
|
:disabled="projects.length === 0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl p-4 sm:p-6">
|
<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 mb-4">
|
<div v-if="source === 'fixture'" role="alert" class="alert alert-warning">
|
||||||
<span>
|
<span>
|
||||||
Showing demo data. Set <code>NAPTA_API_TOKEN</code> and <code>SLACK_BOT_TOKEN</code> on
|
Showing demo data. Set <code>NAPTA_CLIENT_ID</code> /
|
||||||
the server to load your real projects.
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,12 +139,17 @@ watch(selectedProjectId, async (id) => {
|
|||||||
<span>Could not load projects: {{ projectsError }}</span>
|
<span>Could not load projects: {{ projectsError }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MemberGrid
|
<div v-else-if="groups.length === 0" class="py-20 text-center opacity-60">
|
||||||
v-else
|
<p>Pick one or more projects to see their teams.</p>
|
||||||
:members="members"
|
</div>
|
||||||
:loading="loadingMembers"
|
|
||||||
:error="membersError"
|
<ProjectGroup
|
||||||
:project-selected="!!selectedProjectId"
|
v-for="g in groups"
|
||||||
|
:key="g.project.id"
|
||||||
|
:project="g.project"
|
||||||
|
:members="g.members"
|
||||||
|
:loading="g.loading"
|
||||||
|
:error="g.error"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,3 +16,12 @@ export function fetchProjects(): Promise<ProjectsResponse> {
|
|||||||
export function fetchMembers(projectId: string): Promise<MembersResponse> {
|
export function fetchMembers(projectId: string): Promise<MembersResponse> {
|
||||||
return getJson<MembersResponse>(`/api/projects/${encodeURIComponent(projectId)}/members`)
|
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">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
modelValue: string
|
modelValue: string[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:modelValue": [value: string]
|
"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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select
|
<div class="flex items-center gap-2">
|
||||||
class="select select-bordered w-72 max-w-full"
|
<div v-if="selectedProjects.length" class="hidden max-w-md flex-wrap gap-1 lg:flex">
|
||||||
:value="modelValue"
|
<span v-for="p in selectedProjects" :key="p.id" class="badge badge-primary gap-1">
|
||||||
:disabled="disabled"
|
{{ p.name }}
|
||||||
aria-label="Select a project"
|
<button
|
||||||
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
class="leading-none"
|
||||||
>
|
type="button"
|
||||||
<option v-if="projects.length === 0" value="">No projects available</option>
|
:aria-label="`Remove ${p.name}`"
|
||||||
<option v-for="p in projects" :key="p.id" :value="p.id">
|
@click="remove(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>
|
</template>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface Member {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DataSource = "napta" | "fixture"
|
export type DataSource = "napta" | "fixture"
|
||||||
|
export type SlackStatus = "ok" | "degraded" | "unconfigured"
|
||||||
|
|
||||||
export interface ProjectsResponse {
|
export interface ProjectsResponse {
|
||||||
source: DataSource
|
source: DataSource
|
||||||
@@ -23,5 +24,6 @@ export interface ProjectsResponse {
|
|||||||
|
|
||||||
export interface MembersResponse {
|
export interface MembersResponse {
|
||||||
source: DataSource
|
source: DataSource
|
||||||
|
slack: SlackStatus
|
||||||
members: Member[]
|
members: Member[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user