fix(ux): focus visibility, tap targets, form labels, surfaced errors
- S6: unlayered :focus-visible ring for form controls and links so keyboard focus is perceivable (was focus:outline-none + 1px border) - S5: >=44px touch targets at the named hotspots (dashboard actions, list-row buttons, ops pills/icons, customize number inputs); dense rows use a 44px hit area without growing row height - S8: 28 form fields associated via id/htmlFor; search inputs get aria-label - S7: claim/complete/delete/link handlers now surface result.error via toast and stop instead of failing silently
This commit is contained in:
@@ -110,13 +110,13 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
|||||||
<form onSubmit={onSubmit} className="p-6 space-y-4">
|
<form onSubmit={onSubmit} className="p-6 space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
|
<label htmlFor="contact-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Name *</label>
|
||||||
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
<input id="contact-name" type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
<label htmlFor="contact-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
||||||
<select value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))}
|
<select id="contact-type" value={form.contact_type} onChange={e => setForm(f => ({ ...f, contact_type: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
||||||
{Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)}
|
{Object.keys(CATEGORY_COLORS).map(t => <option key={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -124,31 +124,31 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
|
<label htmlFor="contact-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email</label>
|
||||||
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
<input id="contact-email" type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
<label htmlFor="contact-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||||
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
<input id="contact-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
|
<label htmlFor="contact-details" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Details / Notes</label>
|
||||||
<textarea value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
|
<textarea id="contact-details" value={form.details} onChange={e => setForm(f => ({ ...f, details: e.target.value }))}
|
||||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
|
<label htmlFor="contact-group-label" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Group Label</label>
|
||||||
<input type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
|
<input id="contact-group-label" type="text" value={form.group_label} onChange={e => setForm(f => ({ ...f, group_label: e.target.value }))}
|
||||||
placeholder="e.g. Equipment Vendors"
|
placeholder="e.g. Equipment Vendors"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
|
<label htmlFor="contact-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags</label>
|
||||||
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
<input id="contact-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||||
placeholder="laser, cnc, maintenance"
|
placeholder="laser, cnc, maintenance"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +184,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
aria-label="Search contacts"
|
||||||
placeholder="Search contacts..."
|
placeholder="Search contacts..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
@@ -220,14 +221,14 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac
|
|||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(contact)}
|
onClick={() => openEdit(contact)}
|
||||||
className="text-muted-foreground hover:text-primary transition"
|
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition"
|
||||||
aria-label="Edit contact"
|
aria-label="Edit contact"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(contact.id)}
|
onClick={() => handleDelete(contact.id)}
|
||||||
className="text-muted-foreground hover:text-destructive transition"
|
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition"
|
||||||
aria-label="Delete contact"
|
aria-label="Delete contact"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
|
|||||||
<ul className="divide-y divide-border">
|
<ul className="divide-y divide-border">
|
||||||
{[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => (
|
{[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => (
|
||||||
<li key={a.id} className={`py-3 flex items-center gap-3 flex-wrap ${a.is_archived ? 'opacity-50' : ''}`}>
|
<li key={a.id} className={`py-3 flex items-center gap-3 flex-wrap ${a.is_archived ? 'opacity-50' : ''}`}>
|
||||||
<input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
<input type="number" defaultValue={a.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
|
||||||
{a.icon && <span>{a.icon}</span>}
|
{a.icon && <span>{a.icon}</span>}
|
||||||
<input type="text" defaultValue={a.name} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
<input type="text" defaultValue={a.name} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{a.code}</span>
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{a.code}</span>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
|
|||||||
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
|
{[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => (
|
||||||
<li key={s.id} className="border border-border rounded p-4">
|
<li key={s.id} className="border border-border rounded p-4">
|
||||||
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
||||||
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
<input type="number" defaultValue={s.sort_order} disabled={!isAdmin} onBlur={async e => { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-2 min-h-[44px] focus:outline-none focus:border-primary" />
|
||||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase">{s.step_type.replace(/_/g, ' ')}</span>
|
||||||
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
<input type="text" defaultValue={s.title} disabled={!isAdmin} onBlur={async e => { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" />
|
||||||
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
|
{s.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default async function DashboardPage() {
|
|||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||||
<PageTitle>Dashboard</PageTitle>
|
<PageTitle>Dashboard</PageTitle>
|
||||||
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition">
|
<Link href="/tasks" className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition">
|
||||||
<IcoPlus className="w-3.5 h-3.5" />
|
<IcoPlus className="w-3.5 h-3.5" />
|
||||||
<span className="hidden sm:inline">Quick Task</span>
|
<span className="hidden sm:inline">Quick Task</span>
|
||||||
<span className="sm:hidden">Task</span>
|
<span className="sm:hidden">Task</span>
|
||||||
@@ -211,7 +211,7 @@ export default async function DashboardPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span>
|
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded">OPEN</span>
|
||||||
)}
|
)}
|
||||||
<Link href="/tasks" className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition">
|
<Link href="/tasks" className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition">
|
||||||
CLAIM
|
CLAIM
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,17 +101,15 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
|
|
||||||
async function handleApprove(memberId: string) {
|
async function handleApprove(memberId: string) {
|
||||||
const result = await approveMember(memberId)
|
const result = await approveMember(memberId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m))
|
setMembers(prev => prev.map(m => m.id === memberId ? { ...m, status: 'current', approved: true } : m))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(memberId: string) {
|
async function handleRemove(memberId: string) {
|
||||||
if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return
|
if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return
|
||||||
const result = await removeMember(memberId)
|
const result = await removeMember(memberId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
setMembers(prev => prev.filter(m => m.id !== memberId))
|
setMembers(prev => prev.filter(m => m.id !== memberId))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(m: Member) {
|
function openEdit(m: Member) {
|
||||||
@@ -133,32 +131,32 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
|
<label htmlFor="member-display-name" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Full Name *</label>
|
||||||
<input type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
|
<input id="member-display-name" type="text" required value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
|
<label htmlFor="member-email" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Email *</label>
|
||||||
<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
<input id="member-email" type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
<label htmlFor="member-phone" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Phone</label>
|
||||||
<input type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
<input id="member-phone" type="tel" value={form.phone} onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
|
<label htmlFor="member-handle" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Handle</label>
|
||||||
<input type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
|
<input id="member-handle" type="text" value={form.handle} placeholder="@username" onChange={e => setForm(f => ({ ...f, handle: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
|
<label htmlFor="member-tier" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tier</label>
|
||||||
<select value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
|
<select id="member-tier" value={form.tier} onChange={e => setForm(f => ({ ...f, tier: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
||||||
<option value="plus">Plus</option>
|
<option value="plus">Plus</option>
|
||||||
<option value="basic">Basic</option>
|
<option value="basic">Basic</option>
|
||||||
@@ -167,8 +165,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
|
<label htmlFor="member-role" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Role</label>
|
||||||
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
<select id="member-role" value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
||||||
<option value="member">Member</option>
|
<option value="member">Member</option>
|
||||||
<option value="board">Board</option>
|
<option value="board">Board</option>
|
||||||
@@ -179,8 +177,8 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
|
<label htmlFor="member-joined-at" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Joined At</label>
|
||||||
<input type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
|
<input id="member-joined-at" type="date" value={form.joined_at?.slice(0, 10)} onChange={e => setForm(f => ({ ...f, joined_at: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 pt-6">
|
<div className="flex items-center gap-3 pt-6">
|
||||||
@@ -238,6 +236,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
aria-label="Search members"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
@@ -326,20 +325,20 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
{m.status === 'unverified' && (
|
{m.status === 'unverified' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(m.id)}
|
onClick={() => handleApprove(m.id)}
|
||||||
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-2 py-0.5 rounded hover:bg-primary/10 transition"
|
className="font-mono text-[10px] border border-primary/30 text-primary bg-primary/5 px-3 py-2 min-h-[44px] rounded hover:bg-primary/10 transition"
|
||||||
>
|
>
|
||||||
APPROVE
|
APPROVE
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(m)}
|
onClick={() => openEdit(m)}
|
||||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||||
>
|
>
|
||||||
EDIT
|
EDIT
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(m.id)}
|
onClick={() => handleRemove(m.id)}
|
||||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-red-300 hover:text-red-600 transition"
|
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-red-300 hover:text-red-600 transition"
|
||||||
>
|
>
|
||||||
REMOVE
|
REMOVE
|
||||||
</button>
|
</button>
|
||||||
@@ -347,7 +346,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo
|
|||||||
<select
|
<select
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }}
|
onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }}
|
||||||
className="font-mono text-[10px] border border-border px-1.5 py-0.5 rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
|
className="font-mono text-[10px] border border-border px-2 py-2 min-h-[44px] rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
|
||||||
title="Assign this member as an area lead"
|
title="Assign this member as an area lead"
|
||||||
>
|
>
|
||||||
<option value="">+ area lead</option>
|
<option value="">+ area lead</option>
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
|||||||
{canManageAcl && (
|
{canManageAcl && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAcl(v => !v)}
|
onClick={() => setShowAcl(v => !v)}
|
||||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
className="flex items-center font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||||
title="Who can access this secret"
|
title="Who can access this secret"
|
||||||
>
|
>
|
||||||
Access
|
Access
|
||||||
@@ -406,13 +406,13 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial
|
|||||||
<button
|
<button
|
||||||
onClick={reveal}
|
onClick={reveal}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center gap-1 font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition"
|
className="flex items-center gap-1 font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition"
|
||||||
title={revealed ? 'Hide' : 'Reveal'}
|
title={revealed ? 'Hide' : 'Reveal'}
|
||||||
>
|
>
|
||||||
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
{loading ? '...' : revealed ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
|
||||||
{revealed ? 'Hide' : 'Reveal'}
|
{revealed ? 'Hide' : 'Reveal'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
<button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,10 +497,10 @@ function KbEntryRow({
|
|||||||
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
|
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
<button onClick={handleEdit} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
|
<button onClick={handleEdit} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-primary transition" title="Edit">
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
|
<button onClick={handleDelete} className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-red-500 transition" title="Delete">
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,16 +101,15 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
|||||||
async function handleLink(paymentId: string, memberId: string) {
|
async function handleLink(paymentId: string, memberId: string) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await linkPaymentToMember(paymentId, memberId)
|
const result = await linkPaymentToMember(paymentId, memberId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); setLoading(false); return }
|
||||||
const linkedMember = members.find(m => m.id === memberId)
|
const linkedMember = members.find(m => m.id === memberId)
|
||||||
setPayments(prev => prev.map(p => p.id === paymentId ? {
|
setPayments(prev => prev.map(p => p.id === paymentId ? {
|
||||||
...p,
|
...p,
|
||||||
member_id: memberId,
|
member_id: memberId,
|
||||||
link_status: 'linked',
|
link_status: 'linked',
|
||||||
space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
|
space_members: linkedMember ? { display_name: linkedMember.display_name } : null,
|
||||||
} : p))
|
} : p))
|
||||||
setLinkingPayment(null)
|
setLinkingPayment(null)
|
||||||
}
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,29 +272,29 @@ export function PaymentsClient({ payments: initialPayments, members, integration
|
|||||||
<form onSubmit={handleLogCash} className="p-6 space-y-4">
|
<form onSubmit={handleLogCash} className="p-6 space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
|
<label htmlFor="cash-amount" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Amount *</label>
|
||||||
<input type="number" step="0.01" min="0" required value={cashForm.amount}
|
<input id="cash-amount" type="number" step="0.01" min="0" required value={cashForm.amount}
|
||||||
onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))}
|
onChange={e => setCashForm(f => ({ ...f, amount: e.target.value }))}
|
||||||
placeholder="50.00"
|
placeholder="50.00"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
|
<label htmlFor="cash-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Date</label>
|
||||||
<input type="date" value={cashForm.date}
|
<input id="cash-date" type="date" value={cashForm.date}
|
||||||
onChange={e => setCashForm(f => ({ ...f, date: e.target.value }))}
|
onChange={e => setCashForm(f => ({ ...f, date: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
|
<label htmlFor="cash-from-note" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Note / From *</label>
|
||||||
<input type="text" required value={cashForm.from_note}
|
<input id="cash-from-note" type="text" required value={cashForm.from_note}
|
||||||
onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))}
|
onChange={e => setCashForm(f => ({ ...f, from_note: e.target.value }))}
|
||||||
placeholder="John Smith — March dues"
|
placeholder="John Smith — March dues"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
|
<label htmlFor="cash-member" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Link to Member</label>
|
||||||
<select value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))}
|
<select id="cash-member" value={cashForm.member_id} onChange={e => setCashForm(f => ({ ...f, member_id: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
||||||
<option value="">— Unlinked —</option>
|
<option value="">— Unlinked —</option>
|
||||||
{members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)}
|
{members.map(m => <option key={m.id} value={m.id}>{m.display_name} ({m.email})</option>)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, X } from 'lucide-react'
|
import { Plus, X } from 'lucide-react'
|
||||||
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
|
import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { useConfirm } from '@/components/ui/confirm'
|
import { useConfirm } from '@/components/ui/confirm'
|
||||||
import type { Tables } from '@/types/database'
|
import type { Tables } from '@/types/database'
|
||||||
import { PageTitle } from '@/components/ui/page-title'
|
import { PageTitle } from '@/components/ui/page-title'
|
||||||
@@ -55,15 +56,15 @@ export function ProjectsClient({
|
|||||||
|
|
||||||
async function handleStatusChange(projectId: string, newStatus: string) {
|
async function handleStatusChange(projectId: string, newStatus: string) {
|
||||||
const result = await updateProjectStatus(projectId, newStatus)
|
const result = await updateProjectStatus(projectId, newStatus)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p))
|
setProjects(prev => prev.map(p => p.id === projectId ? { ...p, status: newStatus } : p))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(projectId: string) {
|
async function handleDelete(projectId: string) {
|
||||||
if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return
|
||||||
const result = await deleteProject(projectId)
|
const result = await deleteProject(projectId)
|
||||||
if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId))
|
if (result?.error) { toast.error(result.error); return }
|
||||||
|
setProjects(prev => prev.filter(p => p.id !== projectId))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,7 +90,7 @@ export function ProjectsClient({
|
|||||||
<EmptyContent>
|
<EmptyContent>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 min-h-[44px] rounded hover:bg-primary/90 transition"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" /> New Project
|
<Plus className="w-3.5 h-3.5" /> New Project
|
||||||
</button>
|
</button>
|
||||||
@@ -172,34 +173,34 @@ export function ProjectsClient({
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
|
<label htmlFor="project-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
|
||||||
<input type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
<input id="project-title" type="text" required autoFocus value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||||
placeholder="CNC Controller Upgrade"
|
placeholder="CNC Controller Upgrade"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
<label htmlFor="project-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
||||||
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
<textarea id="project-description" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
rows={2} className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition resize-none" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
<label htmlFor="project-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
||||||
<select value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
<select id="project-area" value={form.area} onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary">
|
||||||
<option value="">No area</option>
|
<option value="">No area</option>
|
||||||
{AREAS.map(a => <option key={a}>{a}</option>)}
|
{AREAS.map(a => <option key={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
<label htmlFor="project-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
||||||
<input type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
|
<input id="project-due-date" type="date" value={form.due_date} onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
|
<label htmlFor="project-tags" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Tags (comma-separated)</label>
|
||||||
<input type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
<input id="project-tags" type="text" value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||||
placeholder="electronics, hardware, cnc"
|
placeholder="electronics, hardware, cnc"
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { Plus, X, CheckCircle2 } from 'lucide-react'
|
import { Plus, X, CheckCircle2 } from 'lucide-react'
|
||||||
import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions'
|
import { createTask, claimTask, completeTask, deleteTask } from '@/lib/actions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import type { Tables } from '@/types/database'
|
import type { Tables } from '@/types/database'
|
||||||
import { PageTitle } from '@/components/ui/page-title'
|
import { PageTitle } from '@/components/ui/page-title'
|
||||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
|
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty'
|
||||||
@@ -80,28 +81,25 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
|
|
||||||
async function handleClaim(taskId: string) {
|
async function handleClaim(taskId: string) {
|
||||||
const result = await claimTask(taskId)
|
const result = await claimTask(taskId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
const member = members.find(m => m.user_id === currentUserId)
|
const member = members.find(m => m.user_id === currentUserId)
|
||||||
setTasks(prev => prev.map(t =>
|
setTasks(prev => prev.map(t =>
|
||||||
t.id === taskId
|
t.id === taskId
|
||||||
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
|
? { ...t, status: 'claimed', claimed_by: currentUserId, claimed_by_name: member?.display_name }
|
||||||
: t
|
: t
|
||||||
))
|
))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComplete(taskId: string) {
|
async function handleComplete(taskId: string) {
|
||||||
const result = await completeTask(taskId)
|
const result = await completeTask(taskId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
|
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, status: 'completed' } : t))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(taskId: string) {
|
async function handleDelete(taskId: string) {
|
||||||
const result = await deleteTask(taskId)
|
const result = await deleteTask(taskId)
|
||||||
if (!result.error) {
|
if (result?.error) { toast.error(result.error); return }
|
||||||
setTasks(prev => prev.filter(t => t.id !== taskId))
|
setTasks(prev => prev.filter(t => t.id !== taskId))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabCounts = {
|
const tabCounts = {
|
||||||
@@ -201,7 +199,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
{task.status === 'open' && (
|
{task.status === 'open' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClaim(task.id)}
|
onClick={() => handleClaim(task.id)}
|
||||||
className="font-mono text-[10px] border border-border px-2 py-0.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
|
className="font-mono text-[10px] border border-border px-3 py-2 min-h-[44px] rounded hover:border-primary hover:text-primary transition whitespace-nowrap"
|
||||||
>
|
>
|
||||||
CLAIM
|
CLAIM
|
||||||
</button>
|
</button>
|
||||||
@@ -210,7 +208,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
{(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && (
|
{(task.status === 'claimed' || task.status === 'in_progress') && task.claimed_by === currentUserId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleComplete(task.id)}
|
onClick={() => handleComplete(task.id)}
|
||||||
className="font-mono text-[10px] border border-green-200 text-green-700 px-2 py-0.5 rounded hover:bg-green-50 transition whitespace-nowrap"
|
className="font-mono text-[10px] border border-green-200 text-green-700 px-3 py-2 min-h-[44px] rounded hover:bg-green-50 transition whitespace-nowrap"
|
||||||
>
|
>
|
||||||
DONE
|
DONE
|
||||||
</button>
|
</button>
|
||||||
@@ -218,7 +216,7 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(task.id)}
|
onClick={() => handleDelete(task.id)}
|
||||||
className="text-muted-foreground hover:text-destructive transition flex-shrink-0"
|
className="flex items-center justify-center min-w-[44px] min-h-[44px] -my-2 text-muted-foreground hover:text-destructive transition flex-shrink-0"
|
||||||
aria-label="Delete task"
|
aria-label="Delete task"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
@@ -258,8 +256,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
|
|
||||||
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
<form onSubmit={handleCreate} className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
|
<label htmlFor="task-title" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title *</label>
|
||||||
<input
|
<input
|
||||||
|
id="task-title"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||||
@@ -271,8 +270,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
<label htmlFor="task-description" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="task-description"
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
placeholder="Optional details..."
|
placeholder="Optional details..."
|
||||||
@@ -283,8 +283,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
<label htmlFor="task-type" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Type</label>
|
||||||
<select
|
<select
|
||||||
|
id="task-type"
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
||||||
@@ -294,8 +295,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
<label htmlFor="task-area" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Area</label>
|
||||||
<select
|
<select
|
||||||
|
id="task-area"
|
||||||
value={form.area}
|
value={form.area}
|
||||||
onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, area: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
||||||
@@ -308,8 +310,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
|
<label htmlFor="task-recurrence" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Recurrence</label>
|
||||||
<select
|
<select
|
||||||
|
id="task-recurrence"
|
||||||
value={form.recurrence}
|
value={form.recurrence}
|
||||||
onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, recurrence: e.target.value }))}
|
||||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
||||||
@@ -318,8 +321,9 @@ export function TasksClient({ tasks: initialTasks, members, currentUserId, space
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
<label htmlFor="task-due-date" className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Due Date</label>
|
||||||
<input
|
<input
|
||||||
|
id="task-due-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={form.due_date}
|
value={form.due_date}
|
||||||
onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, due_date: e.target.value }))}
|
||||||
|
|||||||
@@ -125,3 +125,18 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keyboard focus visibility (WCAG 2.4.7). Many form controls and links use
|
||||||
|
* `focus:outline-none` with only a 1px border color shift, which is not a
|
||||||
|
* perceivable focus indicator. This rule is intentionally unlayered so it
|
||||||
|
* wins over Tailwind's utilities layer without !important. Scoped to form
|
||||||
|
* controls and links; buttons already get a focus-visible ring from
|
||||||
|
* components/ui/button. :where() keeps specificity at 0 so a component can
|
||||||
|
* still opt out with its own rule.
|
||||||
|
*/
|
||||||
|
:where(input, select, textarea, a[href]):focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function OpsAclEditor({ entityType, entityId, options, initial }: Props)
|
|||||||
key={o.value}
|
key={o.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(o.value, !on)}
|
onClick={() => toggle(o.value, !on)}
|
||||||
className={`font-mono text-[10px] px-2 py-1 rounded border transition ${
|
className={`flex items-center font-mono text-[10px] px-3 py-2 min-h-[44px] rounded border transition ${
|
||||||
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
on ? 'bg-primary/10 text-primary border-primary/30' : 'border-border text-muted-foreground hover:border-primary hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user