Reference UI fix (#132)
* feat: display invoice references as tags instead of plain text Replace the single-line text input for "Er referens" with a tag-style input that lets users add/remove individual references as chips. Updates the detail page, review modal, and PDF template to render comma-separated references as wrapped badge/tag elements instead of a single string. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fixed our reference field --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d9c95a7b59
commit
6d84828fcc
@@ -613,15 +613,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</span>
|
||||
</div>
|
||||
{invoice.your_reference && (
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">Er referens</span>
|
||||
<span>{invoice.your_reference}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{invoice.your_reference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
{ref.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{invoice.our_reference && (
|
||||
<div className="flex justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-muted-foreground">Vår referens</span>
|
||||
<span>{invoice.our_reference}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{invoice.our_reference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
{ref.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { addDays, format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { TagInput } from '@/components/ui/tag-input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
@@ -653,15 +654,32 @@ export default function NewInvoicePage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Er referens</Label>
|
||||
<Input
|
||||
placeholder="Kontaktperson hos kund"
|
||||
{...register('your_reference')}
|
||||
<Controller
|
||||
name="your_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder="Kontaktperson hos kund"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Vår referens</Label>
|
||||
<Input placeholder="Ditt namn" {...register('our_reference')} />
|
||||
<Controller
|
||||
name="our_reference"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TagInput
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
placeholder="Ditt namn"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -159,9 +159,31 @@ export function InvoiceReviewContent({
|
||||
|
||||
{/* References/notes */}
|
||||
{(yourReference || ourReference || notes) && (
|
||||
<div className="border-t pt-3 space-y-1 text-sm text-muted-foreground">
|
||||
{yourReference && <p>Er referens: {yourReference}</p>}
|
||||
{ourReference && <p>Vår referens: {ourReference}</p>}
|
||||
<div className="border-t pt-3 space-y-2 text-sm text-muted-foreground">
|
||||
{yourReference && (
|
||||
<div>
|
||||
<span>Er referens:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{yourReference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
{ref.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ourReference && (
|
||||
<div>
|
||||
<span>Vår referens:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{ourReference.split(',').map((ref, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-normal">
|
||||
{ref.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{notes && <p>Anteckning: {notes}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TagInputProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
({ value = '', onChange, placeholder, className, disabled }, ref) => {
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => inputRef.current!)
|
||||
|
||||
const tags = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
[value]
|
||||
)
|
||||
|
||||
function commitTag(raw: string) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return
|
||||
const next = [...tags, trimmed].join(', ')
|
||||
onChange?.(next)
|
||||
setInputValue('')
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
const next = tags.filter((_, i) => i !== index).join(', ')
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
commitTag(inputValue)
|
||||
} else if (
|
||||
e.key === 'Backspace' &&
|
||||
inputValue === '' &&
|
||||
tags.length > 0
|
||||
) {
|
||||
removeTag(tags.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (inputValue.trim()) {
|
||||
commitTag(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
const pasted = e.clipboardData.getData('text')
|
||||
if (pasted.includes(',')) {
|
||||
e.preventDefault()
|
||||
const newTags = pasted
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
const next = [...tags, ...newTags].join(', ')
|
||||
onChange?.(next)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-colors',
|
||||
'focus-within:ring-1 focus-within:ring-ring',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{tags.map((tag, i) => (
|
||||
<span
|
||||
key={`${tag}-${i}`}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground"
|
||||
>
|
||||
{tag}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeTag(i)
|
||||
}}
|
||||
className="rounded-sm opacity-60 hover:opacity-100 focus:outline-none"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
placeholder={tags.length === 0 ? placeholder : undefined}
|
||||
disabled={disabled}
|
||||
className="min-w-[80px] flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
TagInput.displayName = 'TagInput'
|
||||
|
||||
export { TagInput }
|
||||
@@ -348,15 +348,27 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
|
||||
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
|
||||
</View>
|
||||
{invoice.your_reference && (
|
||||
<View style={styles.row}>
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={styles.label}>Er referens:</Text>
|
||||
<Text style={styles.value}>{invoice.your_reference}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
|
||||
{invoice.your_reference.split(',').map((ref, i) => (
|
||||
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
|
||||
{ref.trim()}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{invoice.our_reference && (
|
||||
<View style={styles.row}>
|
||||
<View style={{ marginBottom: 4 }}>
|
||||
<Text style={styles.label}>Vår referens:</Text>
|
||||
<Text style={styles.value}>{invoice.our_reference}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
|
||||
{invoice.our_reference.split(',').map((ref, i) => (
|
||||
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
|
||||
{ref.trim()}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user