Sprint: ideeen aanpassen (#211)
* feat(user-settings): voeg IdeasListPrefs schema toe met filterStatuses Nieuw IdeasListPrefs-subschema met filterStatuses (array van IdeaStatusApi-waarden), ingehangen als views.ideasList in ViewsPrefs. Testdekking voor geldig, ongeldig en leeg filterStatuses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: extraheer MultiFilterPills naar backlog-filter-popover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): voeg IdeasFilterPopover component toe Nieuwe client-component met multi-select statusfilter popover voor het Ideeënscherm; hergebruikt MultiFilterPills uit backlog-filter-popover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): vervang inline statuschips door IdeasFilterPopover met user-settings persistentie Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ideas): voeg componenttests toe voor IdeasFilterPopover en persistentie Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): voeg activeProductId-prop toe aan IdeaList IdeaListProps uitgebreid met activeProductId: string | null. Create-form initialiseert en reset naar het actieve product na aanmaken. Tests en page.tsx bijgewerkt (page.tsx krijgt echte waarde in volgende taak). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): resolve active_product_id en geef door aan IdeaList Haalt active_product_id op via prisma.user.findUnique en resolveert het tegen de al opgehaalde toegankelijke productenlijst (AC4). Geeft het resultaat als prop door aan IdeaList in plaats van hardcoded null. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): stuur activeProductId mee bij snel idee aanmaken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ideas): voeg component-tests toe voor activeProductId-voorvulling AC1: "Nieuw idee"-select voorgevuld met activeProductId AC2: "Nieuw idee"-select leeg bij activeProductId=null AC3: "Snel idee" stuurt product_id=activeProductId mee bij createIdeaAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(notifications): toon textarea altijd in answer-modal naast opties Vervang opties-XOR-textarea door twee onafhankelijke blokken: opties alleen wanneer aanwezig, vrij tekstveld altijd zichtbaar. Bij opties een visuele scheiding (border-t) en label 'Of typ een eigen antwoord'. Verstuur-knop nu altijd in footer zichtbaar (was verborgen bij opties). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(notifications): gebruik question.options?.length als conditie Gebruik de kortere optional chaining variant consistent, conform plan. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(notifications): voeg component-tests toe voor AnswerModal Dekt: optieknoppen + textarea + Verstuur zichtbaar met opties, submit via optieknop, submit via vrij tekstveld, disabled Verstuur bij leeg veld, en demo-modus (textarea + Verstuur disabled). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(notifications): werk answer-modal spec bij voor vrije tekstveld naast opties Beschrijft dat textarea + Verstuur altijd zichtbaar zijn in multiple-choice mode. Corrigeert de Cmd/Ctrl+Enter-bullet: shortcut werkt nu ook daar. Bijgewerkt naar 2026-05-15. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d5c22382c
commit
00af559726
11 changed files with 601 additions and 120 deletions
|
|
@ -12,6 +12,10 @@ import { useMemo, useState, useTransition } from 'react'
|
|||
import { useRouter } from 'next/navigation'
|
||||
import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { IdeasFilterPopover } from '@/components/ideas/ideas-filter-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -61,6 +65,7 @@ interface IdeaListProps {
|
|||
ideas: IdeaDto[]
|
||||
products: ProductOption[]
|
||||
isDemo: boolean
|
||||
activeProductId: string | null
|
||||
}
|
||||
|
||||
const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [
|
||||
|
|
@ -115,14 +120,18 @@ function SortHeader({
|
|||
)
|
||||
}
|
||||
|
||||
export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
||||
export function IdeaList({ ideas, products, isDemo, activeProductId }: IdeaListProps) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
// Filter state
|
||||
const [search, setSearch] = useState('')
|
||||
const [productFilter, setProductFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<Set<IdeaStatusApi>>(new Set())
|
||||
const filterStatuses = useUserSettingsStore(useShallow(
|
||||
(s) => s.entities.settings.views?.ideasList?.filterStatuses ?? []))
|
||||
const setPref = useUserSettingsStore((s) => s.setPref)
|
||||
const statusFilter = useMemo(() => new Set(filterStatuses), [filterStatuses])
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
|
||||
|
||||
// Sort state
|
||||
const [sortKey, setSortKey] = useState<SortKey>('code')
|
||||
|
|
@ -132,7 +141,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [newProductId, setNewProductId] = useState<string>('')
|
||||
const [newProductId, setNewProductId] = useState<string>(activeProductId ?? '')
|
||||
|
||||
// Quick-idea form state
|
||||
const [showQuick, setShowQuick] = useState(false)
|
||||
|
|
@ -197,12 +206,14 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
}
|
||||
|
||||
function toggleStatus(s: IdeaStatusApi) {
|
||||
setStatusFilter((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(s)) next.delete(s)
|
||||
else next.add(s)
|
||||
return next
|
||||
})
|
||||
const next = filterStatuses.includes(s)
|
||||
? filterStatuses.filter((v) => v !== s)
|
||||
: [...filterStatuses, s]
|
||||
void setPref(['views', 'ideasList', 'filterStatuses'], next)
|
||||
}
|
||||
|
||||
function clearStatusFilter() {
|
||||
void setPref(['views', 'ideasList', 'filterStatuses'], [])
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
|
|
@ -225,7 +236,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
toast.success(`Idee aangemaakt (${r.data?.code})`)
|
||||
setNewTitle('')
|
||||
setNewDescription('')
|
||||
setNewProductId('')
|
||||
setNewProductId(activeProductId ?? '')
|
||||
setShowCreate(false)
|
||||
router.refresh()
|
||||
})
|
||||
|
|
@ -239,7 +250,7 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: null })
|
||||
const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: activeProductId })
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
|
|
@ -289,6 +300,15 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
))}
|
||||
</select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<IdeasFilterPopover
|
||||
open={filterPopoverOpen}
|
||||
onOpenChange={setFilterPopoverOpen}
|
||||
statusOptions={STATUS_FILTERS}
|
||||
selected={statusFilter}
|
||||
onToggle={toggleStatus}
|
||||
onClear={clearStatusFilter}
|
||||
activeFilterCount={statusFilter.size}
|
||||
/>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -313,27 +333,6 @@ export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status-chips als multi-select filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUS_FILTERS.map((s) => {
|
||||
const active = statusFilter.has(s.value)
|
||||
return (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => toggleStatus(s.value)}
|
||||
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
|
||||
active
|
||||
? 'bg-primary text-on-primary border-primary'
|
||||
: 'bg-background text-muted-foreground border-input hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Snel idee form — geen product-dropdown */}
|
||||
{showQuick && (
|
||||
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
|
||||
|
|
|
|||
69
components/ideas/ideas-filter-popover.tsx
Normal file
69
components/ideas/ideas-filter-popover.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { MultiFilterPills } from '@/components/shared/backlog-filter-popover'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
import type { IdeaStatusApi } from '@/lib/idea-status'
|
||||
|
||||
interface IdeasFilterPopoverProps {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
statusOptions: Array<{ value: IdeaStatusApi; label: string }>
|
||||
selected: Set<IdeaStatusApi>
|
||||
onToggle: (v: IdeaStatusApi) => void
|
||||
onClear: () => void
|
||||
activeFilterCount: number
|
||||
}
|
||||
|
||||
export function IdeasFilterPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
statusOptions,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
activeFilterCount,
|
||||
}: IdeasFilterPopoverProps) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
{...debugProps('ideas-filter-popover__trigger')}
|
||||
>
|
||||
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 space-y-4"
|
||||
{...debugProps('ideas-filter-popover', 'IdeasFilterPopover', 'components/ideas/ideas-filter-popover.tsx')}
|
||||
>
|
||||
<MultiFilterPills
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onClear={onClear}
|
||||
/>
|
||||
<div className="flex justify-end pt-1 border-t border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={onClear}
|
||||
>
|
||||
Wis filters
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import JobCard from './job-card'
|
||||
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
||||
import { MultiFilterPills } from '@/components/shared/backlog-filter-popover'
|
||||
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter'
|
||||
|
|
@ -34,59 +35,6 @@ const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [
|
|||
|
||||
const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value))
|
||||
|
||||
function MultiFilterPills<T extends string>({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: {
|
||||
label: string
|
||||
options: Array<{ value: T; label: string }>
|
||||
selected: Set<T>
|
||||
onToggle: (v: T) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const allActive = selected.size === 0
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
allActive
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{options.map((opt) => {
|
||||
const active = selected.has(opt.value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onToggle(opt.value)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JobsColumnProps {
|
||||
title: string
|
||||
jobs: JobWithRelations[]
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
|||
{question.question}
|
||||
</div>
|
||||
|
||||
{question.options && question.options.length > 0 ? (
|
||||
{question.options?.length ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -134,28 +134,31 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Typ je antwoord…"
|
||||
rows={5}
|
||||
maxLength={ANSWER_MAX_CHARS}
|
||||
disabled={isDemo}
|
||||
aria-label="Antwoord op Claude's vraag"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
tooLong
|
||||
? 'text-error text-right text-xs'
|
||||
: 'text-muted-foreground text-right text-xs'
|
||||
}
|
||||
>
|
||||
{charsLeft} tekens over
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={question.options?.length ? 'space-y-1 border-t pt-4' : 'space-y-1'}>
|
||||
{question.options?.length ? (
|
||||
<p className="text-muted-foreground text-xs">Of typ een eigen antwoord</p>
|
||||
) : null}
|
||||
<Textarea
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Typ je antwoord…"
|
||||
rows={5}
|
||||
maxLength={ANSWER_MAX_CHARS}
|
||||
disabled={isDemo}
|
||||
aria-label="Antwoord op Claude's vraag"
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
tooLong
|
||||
? 'text-error text-right text-xs'
|
||||
: 'text-muted-foreground text-right text-xs'
|
||||
}
|
||||
>
|
||||
{charsLeft} tekens over
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={entityDialogFooterClasses} data-debug-id="answer-modal__submit">
|
||||
|
|
@ -163,16 +166,14 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
|||
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
||||
Annuleren
|
||||
</Button>
|
||||
{(!question.options || question.options.length === 0) && (
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
onClick={() => submit(answer)}
|
||||
disabled={submitDisabled}
|
||||
>
|
||||
{pending ? 'Bezig…' : 'Verstuur'}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
)}
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
onClick={() => submit(answer)}
|
||||
disabled={submitDisabled}
|
||||
>
|
||||
{pending ? 'Bezig…' : 'Verstuur'}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,59 @@ export function FilterPills<T extends string | number>({
|
|||
)
|
||||
}
|
||||
|
||||
export function MultiFilterPills<T extends string>({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: {
|
||||
label: string
|
||||
options: Array<{ value: T; label: string }>
|
||||
selected: Set<T>
|
||||
onToggle: (v: T) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const allActive = selected.size === 0
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
allActive
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{options.map((opt) => {
|
||||
const active = selected.has(opt.value)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onToggle(opt.value)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent border-border hover:bg-surface-container'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BacklogFilterPopoverProps<S extends string, So extends string> {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue