From ae379eb52aa7e8c3cdc798d50af89834a6d758f0 Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 15 Jul 2026 18:10:03 -0400 Subject: [PATCH 01/18] =?UTF-8?q?devibe:=20remove=20dead=20files=20?= =?UTF-8?q?=E2=80=94=20knip=20batch=201/2=20(mode-toggle,=20notifications,?= =?UTF-8?q?=20insight-actions)=20(#858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified dead via grep (import path + symbol name + next/dynamic scan), then git rm. No consumers found in src/, no test coverage, no dynamic imports referencing any of these paths. - src/components/baseball/coach/ModeToggle.tsx — exports JUCOModeToggle, zero importers repo-wide. Only referenced from stale docs (PHASE_5_JUCO_COACH.md, .helm/ACTIONS.md) describing a wiring into src/components/layout/header.tsx, which no longer exists. - src/components/layout/mode-toggle.tsx — exports ModeToggle/Mode, its only consumer was the dead file above. - src/components/features/notification-center.tsx — duplicate/legacy NotificationCenter; the live one is src/components/golf/calendar/NotificationCenter.tsx. .taskmaster/docs/current-state.md already flagged it "Exists but not used". - src/hooks/use-notifications.ts — duplicate/legacy useNotifications; the live hook is src/hooks/useNotifications.ts (capital N), consumed by the real NotificationCenter. - src/components/golf/coachhelm/insights/{InsightBulkActions,InsightExportModal, InsightFiltersPanel,InsightSearchBar}.tsx — not exported from the insights/ barrel (index.ts only re-exports PlayerFocusAreas/InsightsFeed/InsightListView per its "Wave 1A" comment), zero direct importers, no next/dynamic references. - src/lib/baseball/lifting/use-live-set-sync.ts — exports useLiveSetSync, zero importers; only mentioned in docs/audits (planned-but-never-wired). Gates: typecheck clean, check-cycles clean (33 known cycles, none new), no test files reference any of these paths. Co-authored-by: Fable Integrator Co-authored-by: Claude Fable 5 --- src/components/baseball/coach/ModeToggle.tsx | 25 - .../features/notification-center.tsx | 279 ---------- .../coachhelm/insights/InsightBulkActions.tsx | 280 ---------- .../coachhelm/insights/InsightExportModal.tsx | 252 --------- .../insights/InsightFiltersPanel.tsx | 507 ------------------ .../coachhelm/insights/InsightSearchBar.tsx | 104 ---- src/components/layout/mode-toggle.tsx | 40 -- src/hooks/use-notifications.ts | 123 ----- src/lib/baseball/lifting/use-live-set-sync.ts | 152 ------ 9 files changed, 1762 deletions(-) delete mode 100644 src/components/baseball/coach/ModeToggle.tsx delete mode 100644 src/components/features/notification-center.tsx delete mode 100644 src/components/golf/coachhelm/insights/InsightBulkActions.tsx delete mode 100644 src/components/golf/coachhelm/insights/InsightExportModal.tsx delete mode 100644 src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx delete mode 100644 src/components/golf/coachhelm/insights/InsightSearchBar.tsx delete mode 100644 src/components/layout/mode-toggle.tsx delete mode 100644 src/hooks/use-notifications.ts delete mode 100644 src/lib/baseball/lifting/use-live-set-sync.ts diff --git a/src/components/baseball/coach/ModeToggle.tsx b/src/components/baseball/coach/ModeToggle.tsx deleted file mode 100644 index c9b6295f9..000000000 --- a/src/components/baseball/coach/ModeToggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { useAuth } from '@/hooks/use-auth'; -import { ModeToggle, type Mode } from '@/components/layout/mode-toggle'; - -export function JUCOModeToggle() { - const router = useRouter(); - const { coach, coachMode, setCoachMode } = useAuth(); - - if (!coach || coach.coach_type !== 'juco') return null; - - const handleModeChange = (mode: Mode) => { - setCoachMode(mode as 'recruiting' | 'team'); - if (mode === 'team') { - router.push('/baseball/dashboard/command-center'); - } else { - router.push('/baseball/dashboard/command-center'); - } - }; - - return ( - - ); -} diff --git a/src/components/features/notification-center.tsx b/src/components/features/notification-center.tsx deleted file mode 100644 index 3f36be5ad..000000000 --- a/src/components/features/notification-center.tsx +++ /dev/null @@ -1,279 +0,0 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { IconBell, IconX, IconCheck } from '@/components/icons'; -import { Avatar } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { Button, IconButton } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -interface Notification { - id: string; - type: 'profile_view' | 'watchlist_add' | 'message' | 'evaluation' | 'camp_interest' | 'team_join' | 'team_join_request' | 'team_join_approved' | 'team_join_rejected' | 'other'; - title: string; - message: string; - timestamp: string; - read: boolean; - actionUrl?: string; - actorName?: string; - actorAvatar?: string; -} - -interface NotificationCenterProps { - notifications: Notification[]; - onMarkAsRead: (id: string) => void; - onMarkAllAsRead: () => void; - onDelete: (id: string) => void; - className?: string; -} - -const typeIcons: Record = { - profile_view: { icon: '👁️', color: 'bg-blue-100 text-blue-600' }, - watchlist_add: { icon: '⭐', color: 'bg-amber-100 text-amber-600' }, - message: { icon: '💬', color: 'bg-primary-100 text-primary-600' }, - evaluation: { icon: '📊', color: 'bg-purple-100 text-purple-600' }, - camp_interest: { icon: '🏕️', color: 'bg-cyan-100 text-cyan-600' }, - team_join: { icon: '👋', color: 'bg-primary-50 text-primary-700' }, - team_join_request: { icon: '🙋', color: 'bg-amber-100 text-amber-600' }, - team_join_approved: { icon: '✅', color: 'bg-primary-100 text-primary-600' }, - team_join_rejected: { icon: '❌', color: 'bg-red-100 text-red-600' }, - other: { icon: '🔔', color: 'bg-warm-100 text-warm-600' }, -}; - -function formatTimestamp(timestamp: string): string { - const date = new Date(timestamp); - const now = new Date(); - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); - - if (diffInSeconds < 60) return 'Just now'; - if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; - if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; - if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; - - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); -} - -export function NotificationCenter({ - notifications, - onMarkAsRead, - onMarkAllAsRead, - onDelete, - className -}: NotificationCenterProps) { - const [isOpen, setIsOpen] = useState(false); - const [filter, setFilter] = useState<'all' | 'unread'>('all'); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); - - const unreadCount = notifications.filter(n => !n.read).length; - - const filteredNotifications = filter === 'unread' - ? notifications.filter(n => !n.read) - : notifications; - - // Click outside to close - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) && - !buttonRef.current?.contains(e.target as Node) - ) { - setIsOpen(false); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - return undefined; - }, [isOpen]); - - return ( -
- {/* Bell Button */} - - - {/* Dropdown Panel */} - {isOpen && ( -
-
- {/* Header */} -
-
-

- Notifications - {unreadCount > 0 && ( - - {unreadCount} - - )} -

- {unreadCount > 0 && ( - - )} -
- - {/* Filter Tabs */} -
- - -
-
- - {/* Notifications List */} -
- {filteredNotifications.length === 0 ? ( -
-
- -
-

- {filter === 'unread' ? 'All caught up!' : 'No notifications'} -

-

- {filter === 'unread' - ? 'You have no unread notifications' - : 'Notifications will appear here'} -

-
- ) : ( -
- {filteredNotifications.map((notification) => { - const typeConfig = typeIcons[notification.type] || typeIcons['other']; - const config = typeConfig!; // Assert non-null since we have fallback - - return ( -
-
-
- {/* Icon or Avatar */} - {notification.actorAvatar ? ( - - ) : ( -
- {config.icon} -
- )} - - {/* Content */} -
-

- {notification.title} -

-

- {notification.message} -

-

- {formatTimestamp(notification.timestamp)} -

-
- - {/* Actions */} -
- {!notification.read && ( - onMarkAsRead(notification.id)} - className="p-1 hover:bg-cream-100 active:bg-cream-100/75 rounded transition-colors" - title="Mark as read" - > - - - )} - onDelete(notification.id)} - className="p-1 hover:bg-cream-100 active:bg-cream-100/75 rounded transition-colors" - title="Delete" - > - - -
-
- - {/* Unread indicator */} - {!notification.read && ( -
- )} -
-
- ); - })} -
- )} -
- - {/* Footer */} - {filteredNotifications.length > 0 && ( -
- -
- )} -
- )} -
- ); -} diff --git a/src/components/golf/coachhelm/insights/InsightBulkActions.tsx b/src/components/golf/coachhelm/insights/InsightBulkActions.tsx deleted file mode 100644 index e8a2b14d6..000000000 --- a/src/components/golf/coachhelm/insights/InsightBulkActions.tsx +++ /dev/null @@ -1,280 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; -import { cn } from '@/lib/utils'; -import { Button, IconButton } from '@/components/ui/button'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { triggerHaptic } from '@/lib/utils/capacitor'; -import { - IconCheck, - IconX, - IconDownload, - IconCheckCheck, -} from '@/components/icons'; - -// ============================================================================ -// TYPES -// ============================================================================ - -interface InsightBulkActionsProps { - selectedCount: number; - totalCount: number; - onSelectAll: () => void; - onDeselectAll: () => void; - onBulkDismiss: () => Promise; - onBulkAcknowledge: () => Promise; - onBulkResolve?: () => Promise; - onExport: () => void; - isAllSelected: boolean; - className?: string; -} - -// ============================================================================ -// COMPONENT -// ============================================================================ - -export function InsightBulkActions({ - selectedCount, - totalCount, - onSelectAll, - onDeselectAll, - onBulkDismiss, - onBulkAcknowledge, - onBulkResolve, - onExport, - isAllSelected, - className, -}: InsightBulkActionsProps) { - const prefersReducedMotion = useReducedMotion(); - const [isProcessing, setIsProcessing] = useState(false); - const [confirmModal, setConfirmModal] = useState<{ - open: boolean; - action: 'dismiss' | 'acknowledge' | 'resolve' | null; - }>({ open: false, action: null }); - - const isVisible = selectedCount > 0; - - const handleConfirmAction = async () => { - if (!confirmModal.action) return; - - void triggerHaptic('medium'); - setIsProcessing(true); - - try { - switch (confirmModal.action) { - case 'dismiss': - await onBulkDismiss(); - break; - case 'acknowledge': - await onBulkAcknowledge(); - break; - case 'resolve': - if (onBulkResolve) await onBulkResolve(); - break; - } - } finally { - setIsProcessing(false); - setConfirmModal({ open: false, action: null }); - } - }; - - const getConfirmModalProps = () => { - switch (confirmModal.action) { - case 'dismiss': - return { - title: `Dismiss ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`, - message: 'Dismissed insights will be hidden from your active feed. You can still view them by filtering for dismissed insights.', - confirmLabel: 'Dismiss All', - variant: 'danger' as const, - }; - case 'acknowledge': - return { - title: `Acknowledge ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`, - message: 'Acknowledged insights indicate you have reviewed them. They will remain visible but marked as acknowledged.', - confirmLabel: 'Acknowledge All', - variant: 'default' as const, - }; - case 'resolve': - return { - title: `Resolve ${selectedCount} insight${selectedCount !== 1 ? 's' : ''}?`, - message: 'Resolved insights indicate the issue has been addressed. They will be marked as completed.', - confirmLabel: 'Resolve All', - variant: 'default' as const, - }; - default: - return { - title: '', - message: '', - confirmLabel: '', - variant: 'default' as const, - }; - } - }; - - const modalProps = getConfirmModalProps(); - - return ( - <> - - {isVisible && ( - -
- {/* Selection Info */} -
- {/* Selection Count Badge */} - - {selectedCount} - - - {/* Selection Text */} -
-

- {selectedCount} of {totalCount} selected -

-
- - {/* Select All / Deselect All */} - -
- - {/* Actions */} -
- {/* Acknowledge */} - - - {/* Resolve (optional) */} - {onBulkResolve && ( - - )} - - {/* Dismiss */} - - - {/* Divider */} -
- - {/* Export */} - - - {/* Close */} - - - -
-
- - )} - - - {/* Confirmation Dialog */} - setConfirmModal({ open: false, action: null })} - onConfirm={handleConfirmAction} - title={modalProps.title} - message={modalProps.message} - confirmLabel={modalProps.confirmLabel} - cancelLabel="Cancel" - variant={modalProps.variant} - isLoading={isProcessing} - /> - - ); -} diff --git a/src/components/golf/coachhelm/insights/InsightExportModal.tsx b/src/components/golf/coachhelm/insights/InsightExportModal.tsx deleted file mode 100644 index ec9a7a460..000000000 --- a/src/components/golf/coachhelm/insights/InsightExportModal.tsx +++ /dev/null @@ -1,252 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import { cn } from '@/lib/utils'; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, - DrawerDescription, -} from '@/components/ui/drawer'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { - IconDownload, - IconFile, - IconCheck, -} from '@/components/icons'; -import { exportInsights } from '@/app/golf/actions/insight-management'; - -// ============================================================================ -// TYPES -// ============================================================================ - -type ExportFormat = 'csv' | 'json'; - -interface InsightExportModalProps { - open: boolean; - onClose: () => void; - selectedIds: string[]; - onExportComplete?: () => void; -} - -// ============================================================================ -// FORMAT OPTIONS -// ============================================================================ - -const FORMAT_OPTIONS: { - value: ExportFormat; - label: string; - description: string; - icon: React.ReactNode; -}[] = [ - { - value: 'csv', - label: 'CSV', - description: 'Spreadsheet format for Excel, Google Sheets', - icon: ( -
- CSV -
- ), - }, - { - value: 'json', - label: 'JSON', - description: 'Structured data format for developers', - icon: ( -
- {'{}'} -
- ), - }, -]; - -// ============================================================================ -// COMPONENT -// ============================================================================ - -export function InsightExportModal({ - open, - onClose, - selectedIds, - onExportComplete, -}: InsightExportModalProps) { - const prefersReducedMotion = useReducedMotion(); - const [selectedFormat, setSelectedFormat] = useState('csv'); - const [isExporting, setIsExporting] = useState(false); - const [exportResult, setExportResult] = useState<{ - success: boolean; - message: string; - } | null>(null); - - const handleExport = async () => { - setIsExporting(true); - setExportResult(null); - - try { - const result = await exportInsights(selectedIds, selectedFormat); - - if (result.success && result.data && result.filename && result.mimeType) { - // Create blob and download - const blob = new Blob([result.data], { type: result.mimeType }); - const url = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = result.filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - - setExportResult({ - success: true, - message: `Successfully exported ${selectedIds.length} insight${selectedIds.length !== 1 ? 's' : ''}`, - }); - - // Call callback and close after short delay - setTimeout(() => { - onExportComplete?.(); - onClose(); - }, 1500); - } else { - setExportResult({ - success: false, - message: result.error || 'Export failed', - }); - } - } catch (err) { - console.error('Export error:', err); - setExportResult({ - success: false, - message: 'An unexpected error occurred', - }); - } finally { - setIsExporting(false); - } - }; - - const handleClose = () => { - if (!isExporting) { - setExportResult(null); - onClose(); - } - }; - - return ( - { - if (!next) handleClose(); - }} - > - - - Export Insights - - {`Export ${selectedIds.length} selected insight${selectedIds.length !== 1 ? 's' : ''}`} - - -
- {/* Format Selection */} -
-

- Choose format -

-
- {FORMAT_OPTIONS.map((option) => ( - - ))} -
-
- - {/* Preview Info */} - -
-
- -
-
-

- {selectedIds.length} insight{selectedIds.length !== 1 ? 's' : ''} will be exported -

-

- Includes title, description, player, priority, status, and dates -

-
-
-
- - {/* Result Message */} - {exportResult && ( - - {exportResult.message} - - )} - - {/* Actions */} -
- - -
-
-
-
- ); -} diff --git a/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx b/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx deleted file mode 100644 index c5248fb1d..000000000 --- a/src/components/golf/coachhelm/insights/InsightFiltersPanel.tsx +++ /dev/null @@ -1,507 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; -import { cn } from '@/lib/utils'; -import { Button, IconButton } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Select } from '@/components/ui/select'; -import { Input } from '@/components/ui/input'; -import { - IconFilter, - IconX, - IconChevronDown, - IconChevronUp, -} from '@/components/icons'; -import { - INSIGHT_TYPE_CONFIGS, - type InsightType, - type InsightPriority, - type InsightStatus, -} from '@/lib/coachhelm/insight-types'; - -// ============================================================================ -// TYPES -// ============================================================================ - -export interface InsightFilters { - playerId?: string; - insightType?: InsightType; - priority?: InsightPriority; - status?: InsightStatus; - dateRange?: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'custom'; - startDate?: string; - endDate?: string; -} - -interface FilterOption { - id: string; - name: string; -} - -interface InsightFiltersPanelProps { - filters: InsightFilters; - onFiltersChange: (filters: InsightFilters) => void; - players: FilterOption[]; - className?: string; - defaultExpanded?: boolean; -} - -// ============================================================================ -// CONSTANTS -// ============================================================================ - -const PRIORITY_OPTIONS: { value: InsightPriority; label: string; color: string }[] = [ - { value: 'urgent', label: 'Urgent', color: 'bg-red-100 text-red-700' }, - { value: 'high', label: 'High', color: 'bg-orange-100 text-orange-700' }, - { value: 'medium', label: 'Medium', color: 'bg-yellow-100 text-yellow-700' }, - { value: 'low', label: 'Low', color: 'bg-blue-100 text-blue-700' }, -]; - -const STATUS_OPTIONS: { value: InsightStatus; label: string; color: string }[] = [ - { value: 'active', label: 'Active', color: 'bg-primary-100 text-primary-700' }, - { value: 'acknowledged', label: 'Acknowledged', color: 'bg-blue-100 text-blue-700' }, - { value: 'resolved', label: 'Resolved', color: 'bg-warm-100 text-warm-700' }, - { value: 'dismissed', label: 'Dismissed', color: 'bg-warm-100 text-warm-500' }, -]; - -const DATE_RANGE_OPTIONS = [ - { value: 'last_7_days', label: 'Last 7 days' }, - { value: 'last_30_days', label: 'Last 30 days' }, - { value: 'last_90_days', label: 'Last 90 days' }, - { value: 'custom', label: 'Custom range' }, -]; - -// ============================================================================ -// FILTER CHIP COMPONENT -// ============================================================================ - -interface FilterChipProps { - label: string; - onRemove: () => void; - colorClass?: string; -} - -function FilterChip({ label, onRemove, colorClass = 'bg-primary-100 text-primary-700' }: FilterChipProps) { - const prefersReducedMotion = useReducedMotion(); - return ( - - {label} - { - e.stopPropagation(); - onRemove(); - }} - className="p-0.5 rounded-full hover:bg-black/10 transition-colors" - aria-label={`Remove ${label} filter`} - > - - - - ); -} - -// ============================================================================ -// MAIN COMPONENT -// ============================================================================ - -export function InsightFiltersPanel({ - filters, - onFiltersChange, - players, - className, - defaultExpanded = true, -}: InsightFiltersPanelProps) { - const prefersReducedMotion = useReducedMotion(); - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - const [isMobileOpen, setIsMobileOpen] = useState(false); - - // Count active filters - const activeFilterCount = Object.entries(filters).filter( - ([key, value]) => value && key !== 'startDate' && key !== 'endDate' - ).length; - - // Get insight type options from config - const insightTypeOptions = Object.entries(INSIGHT_TYPE_CONFIGS).map(([type, config]) => ({ - value: type as InsightType, - label: config.label, - icon: config.icon, - })); - - // Update a single filter - const updateFilter = ( - key: K, - value: InsightFilters[K] | undefined - ) => { - const newFilters = { ...filters }; - if (value === undefined || value === '') { - delete newFilters[key]; - } else { - newFilters[key] = value; - } - onFiltersChange(newFilters); - }; - - // Clear all filters - const clearAllFilters = () => { - onFiltersChange({}); - }; - - // Get active filter chips - const getActiveFilterChips = () => { - const chips: { key: string; label: string; colorClass: string; onRemove: () => void }[] = []; - - if (filters.playerId) { - const player = players.find((p) => p.id === filters.playerId); - if (player) { - chips.push({ - key: 'player', - label: player.name, - colorClass: 'bg-purple-100 text-purple-700', - onRemove: () => updateFilter('playerId', undefined), - }); - } - } - - if (filters.insightType) { - const config = INSIGHT_TYPE_CONFIGS[filters.insightType]; - chips.push({ - key: 'type', - label: config.label, - colorClass: 'bg-primary-100 text-primary-700', - onRemove: () => updateFilter('insightType', undefined), - }); - } - - if (filters.priority) { - const option = PRIORITY_OPTIONS.find((o) => o.value === filters.priority); - if (option) { - chips.push({ - key: 'priority', - label: option.label, - colorClass: option.color, - onRemove: () => updateFilter('priority', undefined), - }); - } - } - - if (filters.status) { - const option = STATUS_OPTIONS.find((o) => o.value === filters.status); - if (option) { - chips.push({ - key: 'status', - label: option.label, - colorClass: option.color, - onRemove: () => updateFilter('status', undefined), - }); - } - } - - if (filters.dateRange) { - const option = DATE_RANGE_OPTIONS.find((o) => o.value === filters.dateRange); - if (option) { - chips.push({ - key: 'date', - label: option.label, - colorClass: 'bg-warm-100 text-warm-700', - onRemove: () => { - updateFilter('dateRange', undefined); - updateFilter('startDate', undefined); - updateFilter('endDate', undefined); - }, - }); - } - } - - return chips; - }; - - const filterChips = getActiveFilterChips(); - - // ============================================================================ - // FILTER FORM CONTENT - // ============================================================================ - - const FilterFormContent = () => ( -
- {/* Row 1: Player & Type */} -
- {/* Player Filter */} -
- ({ - value: option.value, - label: `${option.icon} ${option.label}`, - }))} - value={filters.insightType || ''} - onChange={(value) => updateFilter('insightType', (value as InsightType) || undefined)} - placeholder="All types" - clearable - /> -
-
- - {/* Row 2: Priority & Status */} -
- {/* Priority Filter */} -
- ({ value: option.value, label: option.label }))} - value={filters.status || ''} - onChange={(value) => updateFilter('status', (value as InsightStatus) || undefined)} - placeholder="All statuses" - clearable - /> -
-
- - {/* Row 3: Date Range */} -
-
- updateFilter('startDate', e.target.value || undefined)} - /> - updateFilter('endDate', e.target.value || undefined)} - /> - - )} -
-
- - {/* Actions */} - {activeFilterCount > 0 && ( -
- -
- )} -
- ); - - return ( -
- {/* Desktop View */} -
- - {/* Header */} - - - {/* Filter Chips (always visible when filters are active) */} - - {!isExpanded && filterChips.length > 0 && ( - -
- {filterChips.map((chip) => ( - - ))} -
-
- )} -
- - {/* Expanded Content */} - - {isExpanded && ( - -
-
- -
-
-
- )} -
-
-
- - {/* Mobile View - Collapsible Drawer */} -
- {/* Toggle Button */} - - - {/* Filter Chips (visible when filters are active) */} - {filterChips.length > 0 && ( -
- {filterChips.map((chip) => ( - - ))} -
- )} - - {/* Mobile Drawer */} - - {isMobileOpen && ( - <> - {/* Backdrop */} - setIsMobileOpen(false)} - /> - - {/* Drawer */} - - {/* Handle */} -
-
-
-

Filters

- setIsMobileOpen(false)} - className="p-2 rounded-lg text-warm-400 hover:text-warm-600 hover:bg-warm-100 transition-colors active:bg-warm-200" - > - - -
-
- - {/* Content */} -
- -
- -
-
- - - )} - -
-
- ); -} diff --git a/src/components/golf/coachhelm/insights/InsightSearchBar.tsx b/src/components/golf/coachhelm/insights/InsightSearchBar.tsx deleted file mode 100644 index f56c30f82..000000000 --- a/src/components/golf/coachhelm/insights/InsightSearchBar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback, useRef } from 'react'; -import { cn } from '@/lib/utils'; -import { IconSearch } from '@/components/icons'; -import { Input } from '@/components/ui/input'; - -interface InsightSearchBarProps { - value: string; - onChange: (value: string) => void; - placeholder?: string; - debounceMs?: number; - className?: string; -} - -export function InsightSearchBar({ - value, - onChange, - placeholder = 'Search insights...', - debounceMs = 300, - className, -}: InsightSearchBarProps) { - const [localValue, setLocalValue] = useState(value); - const debounceRef = useRef(null); - const inputRef = useRef(null); - - // Sync external value changes - useEffect(() => { - setLocalValue(value); - }, [value]); - - // Debounced onChange - const debouncedOnChange = useCallback( - (newValue: string) => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - - debounceRef.current = setTimeout(() => { - onChange(newValue); - }, debounceMs); - }, - [onChange, debounceMs] - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - }; - }, []); - - const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setLocalValue(newValue); - debouncedOnChange(newValue); - }; - - const handleClear = () => { - setLocalValue(''); - onChange(''); - inputRef.current?.focus(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Immediately trigger search on Enter - if (e.key === 'Enter') { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - onChange(localValue); - } - // Clear on Escape - if (e.key === 'Escape' && localValue) { - handleClear(); - } - }; - - return ( -
- } - clearable - onClear={handleClear} - className="min-h-[44px] py-2.5 rounded-xl" - /> -
- ); -} diff --git a/src/components/layout/mode-toggle.tsx b/src/components/layout/mode-toggle.tsx deleted file mode 100644 index 6bb64c502..000000000 --- a/src/components/layout/mode-toggle.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; - -export type Mode = 'recruiting' | 'team'; - -interface ModeToggleProps { - currentMode: Mode; - onModeChange: (mode: Mode) => void; -} - -export function ModeToggle({ currentMode, onModeChange }: ModeToggleProps) { - return ( -
- - -
- ); -} diff --git a/src/hooks/use-notifications.ts b/src/hooks/use-notifications.ts deleted file mode 100644 index bbc87afaa..000000000 --- a/src/hooks/use-notifications.ts +++ /dev/null @@ -1,123 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { createClient } from '@/lib/supabase/client'; -import { useAuthStore } from '@/stores/auth-store'; - -interface Notification { - id: string; - type: 'profile_view' | 'watchlist_add' | 'message' | 'evaluation' | 'camp_interest' | 'team_join' | 'team_join_request' | 'team_join_approved' | 'team_join_rejected' | 'other'; - title: string; - message: string; - timestamp: string; - read: boolean; - actionUrl?: string; - actorName?: string; - actorAvatar?: string; -} - -export function useNotifications() { - const [notifications, setNotifications] = useState([]); - const [loading, setLoading] = useState(true); - const { user } = useAuthStore(); - const supabase = createClient(); - - const fetchNotifications = useCallback(async () => { - if (!user) { - setLoading(false); - return; - } - - const { data } = await supabase - .from('notifications') - .select('*') - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - .limit(50); - - if (data) { - const mapped: Notification[] = data.map(n => ({ - id: n.id, - type: (n.type || 'other') as Notification['type'], - title: n.title || 'Notification', - message: n.body || '', - timestamp: n.created_at || new Date().toISOString(), - read: n.read || false, - actionUrl: (n as { link?: string }).link || undefined, - })); - setNotifications(mapped); - } - setLoading(false); - }, [user, supabase]); - - useEffect(() => { - fetchNotifications(); - - // Real-time subscription for new notifications - if (user) { - const channel = supabase - .channel('notifications') - .on( - 'postgres_changes', - { - event: 'INSERT', - schema: 'public', - table: 'notifications', - filter: `user_id=eq.${user.id}`, - }, - () => { - fetchNotifications(); - } - ) - .subscribe(); - - return () => { - supabase.removeChannel(channel); - }; - } - return undefined; - }, [user, fetchNotifications, supabase]); - - const markAsRead = async (id: string) => { - await supabase - .from('notifications') - .update({ read: true }) - .eq('id', id); - - setNotifications(prev => - prev.map(n => n.id === id ? { ...n, read: true } : n) - ); - }; - - const markAllAsRead = async () => { - if (!user) return; - - await supabase - .from('notifications') - .update({ read: true }) - .eq('user_id', user.id) - .eq('read', false); - - setNotifications(prev => - prev.map(n => ({ ...n, read: true })) - ); - }; - - const deleteNotification = async (id: string) => { - await supabase - .from('notifications') - .delete() - .eq('id', id); - - setNotifications(prev => prev.filter(n => n.id !== id)); - }; - - return { - notifications, - loading, - markAsRead, - markAllAsRead, - deleteNotification, - refetch: fetchNotifications, - }; -} diff --git a/src/lib/baseball/lifting/use-live-set-sync.ts b/src/lib/baseball/lifting/use-live-set-sync.ts deleted file mode 100644 index 6677c64c0..000000000 --- a/src/lib/baseball/lifting/use-live-set-sync.ts +++ /dev/null @@ -1,152 +0,0 @@ -'use client'; - -// ============================================================================= -// src/lib/baseball/lifting/use-live-set-sync.ts -// -// React lifecycle around the Live Weight Room offline set buffer. Owns the -// flush loop so the component just calls `queueAndFlush(...)` and renders the -// returned `pending`/`isOnline` state — mirroring how GolfHelm's round-entry -// screens lean on the sync-engine rather than open-coding retry logic per page. -// -// Flush triggers (any one drains the durable queue, backoff-gated per entry): -// * mount — recover sets stranded by a prior tab close / reload. -// * `online` event — the moment connectivity returns, push what we have. -// * periodic tick — covers the "still technically online but the request -// failed" case (flaky room Wi-Fi never fires `offline`). -// * after a new log — try immediately so the happy path stays instant. -// -// NON-DESTRUCTIVE: a failed flush bumps backoff and keeps the entry; only a -// confirmed server success removes it. Idempotent server upsert makes replay safe. -// ============================================================================= - -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { - enqueueSet, - readPendingSets, - removeSet, - recordSetFailure, - shouldRetrySet, - pendingSetCount, - type LiveSetEntryInput, -} from './live-set-offline-buffer'; - -/** The capability-enforced server action shape (logSetResult), narrowed. */ -export type FlushSetFn = (input: { - sessionId: string; - sessionExerciseId: string; - setNumber: number; - actualReps: number | null; - actualLoad: number | null; - loadUnit: string | null; - rpe: number | null; -}) => Promise<{ success: boolean; error?: string }>; - -const FLUSH_INTERVAL_MS = 15_000; - -export interface LiveSetSync { - /** Number of sets buffered locally and not yet confirmed by the server. */ - pending: number; - /** Best-effort connectivity (navigator.onLine + last flush outcome). */ - isOnline: boolean; - /** - * Durably buffer a set, then attempt to flush. Returns immediately — the - * caller already applied the optimistic UI change. The set is safe whether or - * not the network is up; if the flush fails it stays queued for the next tick. - */ - queueAndFlush: (input: LiveSetEntryInput) => void; - /** Manually drain the queue (e.g. a "retry now" affordance). */ - flushNow: () => void; -} - -export function useLiveSetSync(flushFn: FlushSetFn): LiveSetSync { - const [pending, setPending] = useState(0); - const [isOnline, setIsOnline] = useState(true); - // A flush in progress — prevents overlapping drains racing the same entries. - const flushingRef = useRef(false); - // Hold the latest flushFn without making the flush callback identity churn. - const flushFnRef = useRef(flushFn); - flushFnRef.current = flushFn; - - const flushNow = useCallback(async () => { - if (flushingRef.current) return; - if (typeof navigator !== 'undefined' && !navigator.onLine) { - setPending(pendingSetCount()); - return; - } - flushingRef.current = true; - try { - const entries = readPendingSets(); - for (const entry of entries) { - if (!shouldRetrySet(entry)) continue; // inside backoff or budget spent - try { - const r = await flushFnRef.current({ - sessionId: entry.sessionId, - sessionExerciseId: entry.sessionExerciseId, - setNumber: entry.setNumber, - actualReps: entry.actualReps, - actualLoad: entry.actualLoad, - loadUnit: entry.loadUnit, - rpe: entry.rpe, - }); - if (r.success) { - removeSet(entry.id); // confirmed — the ONLY destructive op - setIsOnline(true); - } else { - // Server rejected (validation, RLS) — non-destructive backoff. - recordSetFailure(entry.id); - } - } catch { - // Network/transport failure — keep the entry, mark offline, stop the - // pass (the rest will share the same dead network this tick). - recordSetFailure(entry.id); - setIsOnline(false); - break; - } - } - } finally { - flushingRef.current = false; - setPending(pendingSetCount()); - } - }, []); - - const queueAndFlush = useCallback( - (input: LiveSetEntryInput) => { - enqueueSet(input); - setPending(pendingSetCount()); - void flushNow(); - }, - [flushNow], - ); - - // Recover stranded sets on mount + reflect real connectivity. - useEffect(() => { - setPending(pendingSetCount()); - if (typeof navigator !== 'undefined') setIsOnline(navigator.onLine); - void flushNow(); - }, [flushNow]); - - // Reconnect + offline listeners. - useEffect(() => { - if (typeof window === 'undefined') return; - const onOnline = () => { - setIsOnline(true); - void flushNow(); - }; - const onOffline = () => setIsOnline(false); - window.addEventListener('online', onOnline); - window.addEventListener('offline', onOffline); - return () => { - window.removeEventListener('online', onOnline); - window.removeEventListener('offline', onOffline); - }; - }, [flushNow]); - - // Periodic drain — catches failed-while-online (flaky Wi-Fi never fires `offline`). - useEffect(() => { - const t = setInterval(() => void flushNow(), FLUSH_INTERVAL_MS); - return () => clearInterval(t); - }, [flushNow]); - - return { pending, isOnline, queueAndFlush, flushNow }; -} From 3011efaf96b0a34e3cc8e89c454b2d74a5c3949d Mon Sep 17 00:00:00 2001 From: njrini99-code Date: Wed, 15 Jul 2026 18:10:06 -0400 Subject: [PATCH 02/18] =?UTF-8?q?devibe:=20remove=20dead=20files=20?= =?UTF-8?q?=E2=80=94=20knip=20batch=202/2=20(golf/travel=20legacy,=20soren?= =?UTF-8?q?ess=20barrel,=20lift-programs)=20(#859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified dead via grep (import path + symbol name + next/dynamic scan), then git rm. - src/components/golf/travel/{ExpenseForm,ExpenseList,ExpenseSummary,index}.ts(x) — legacy pre-Fairway components. Superseded by src/components/fairway/pages/travel/ Fairway{ExpenseForm,ExpenseList,ExpenseSummary}.tsx, whose own header comments say they're re-skins of "the legacy golf/travel ExpenseList/ExpenseSummary" — i.e. the legacy files are explicitly documented as replaced. Zero live importers (grep for the barrel path and each symbol name comes back empty outside the legacy files themselves). - src/components/lifting/soreness/index.ts — barrel; zero importers (every other file in the same directory — BodySilhouetteFront, SorenessCheckCard, SorenessBodyMap, HighPrioritySorenessList, SorenessScheduleBuilder — IS imported directly by app code, just never through this barrel). - src/components/lifting/soreness/SorenessComplianceBoard.tsx, TeamSorenessHeatmap.tsx — only referenced from the dead barrel above; no direct importers. - src/lib/baseball/read-models/lift-programs.ts — exports getLiftProgramList/ getLiftProgramTree/getAssignContext. The live /performance/programs/[programId] page defines its own local getAssignContext (duplicated, not imported from here) — confirms this read-model was built but never wired in. Gates: typecheck clean, check-cycles clean (33 known cycles, none new). `grep` false-positive check: src/app/golf/actions/__tests__/travel.test.ts matches "ExpenseSummary" only via the substring in getExpenseSummary() (a server action, unrelated file) — ran that suite standalone to confirm (128 passed, 4 skipped, unaffected). Co-authored-by: Fable Integrator Co-authored-by: Claude Fable 5 --- src/components/golf/travel/ExpenseForm.tsx | 433 ------------------ src/components/golf/travel/ExpenseList.tsx | 358 --------------- src/components/golf/travel/ExpenseSummary.tsx | 369 --------------- src/components/golf/travel/index.ts | 3 - .../soreness/SorenessComplianceBoard.tsx | 201 -------- .../lifting/soreness/TeamSorenessHeatmap.tsx | 243 ---------- src/components/lifting/soreness/index.ts | 43 -- src/lib/baseball/read-models/lift-programs.ts | 340 -------------- 8 files changed, 1990 deletions(-) delete mode 100644 src/components/golf/travel/ExpenseForm.tsx delete mode 100644 src/components/golf/travel/ExpenseList.tsx delete mode 100644 src/components/golf/travel/ExpenseSummary.tsx delete mode 100644 src/components/golf/travel/index.ts delete mode 100644 src/components/lifting/soreness/SorenessComplianceBoard.tsx delete mode 100644 src/components/lifting/soreness/TeamSorenessHeatmap.tsx delete mode 100644 src/components/lifting/soreness/index.ts delete mode 100644 src/lib/baseball/read-models/lift-programs.ts diff --git a/src/components/golf/travel/ExpenseForm.tsx b/src/components/golf/travel/ExpenseForm.tsx deleted file mode 100644 index 60d7af90b..000000000 --- a/src/components/golf/travel/ExpenseForm.tsx +++ /dev/null @@ -1,433 +0,0 @@ -'use client'; - -import { useState, useEffect, useId } from 'react'; -import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from '@/components/ui/drawer'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Button, IconButton } from '@/components/ui/button'; -import { - IconUpload, - IconX, - IconHome, - IconAirplane, - IconClipboardList, - IconAward, - IconFlag, - IconLayers, - IconPaperclip, -} from '@/components/icons'; -import type { ComponentType, SVGAttributes } from 'react'; - -type ExpenseIcon = ComponentType & { size?: number }>; -import { - createTravelExpense, - updateTravelExpense, - uploadExpenseReceipt, - type ExpenseCategory, - type ExpensePaidBy, - type TravelExpense, - type CreateExpenseInput, -} from '@/app/golf/actions/travel'; - -interface ExpenseFormProps { - isOpen: boolean; - onClose: () => void; - onSaved: () => void; - teamId: string; - itineraryId?: string | null; - expense?: TravelExpense | null; -} - -const CATEGORIES: { value: ExpenseCategory; label: string; icon: ExpenseIcon }[] = [ - { value: 'lodging', label: 'Lodging', icon: IconHome }, - { value: 'transportation', label: 'Transportation', icon: IconAirplane }, - { value: 'meals', label: 'Meals', icon: IconClipboardList }, - { value: 'entry_fees', label: 'Entry Fees', icon: IconAward }, - { value: 'equipment', label: 'Equipment', icon: IconFlag }, - { value: 'other', label: 'Other', icon: IconLayers }, -]; - -// NOTE: The 'split' paid-by option is intentionally NOT offered here. Cost -// splitting is a deferred feature — the golf_travel_expense_splits table exists -// but has no CRUD/calc anywhere yet, so a "Split" choice would be a dead end. -// The ExpensePaidBy type/schema/summary still accept 'split' so any legacy rows -// render correctly; re-add the option below once splitting is actually built. -const PAID_BY_OPTIONS: { value: ExpensePaidBy; label: string }[] = [ - { value: 'team', label: 'Team' }, - { value: 'player', label: 'Player' }, - { value: 'pending_reimbursement', label: 'Pending Reimbursement' }, -]; - -export function ExpenseForm({ - isOpen, - onClose, - onSaved, - teamId, - itineraryId, - expense, -}: ExpenseFormProps) { - const uid = useId(); - const prefersReducedMotion = useReducedMotion(); - const [loading, setLoading] = useState(false); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - - const [category, setCategory] = useState('other'); - const [description, setDescription] = useState(''); - const [amount, setAmount] = useState(''); - const [vendorName, setVendorName] = useState(''); - const [expenseDate, setExpenseDate] = useState(''); - const [paidBy, setPaidBy] = useState('team'); - const [notes, setNotes] = useState(''); - const [receiptUrl, setReceiptUrl] = useState(null); - const [receiptFile, setReceiptFile] = useState(null); - - // Reset form when expense changes - useEffect(() => { - if (expense) { - setCategory(expense.category); - setDescription(expense.description); - setAmount(expense.amount.toString()); - setVendorName(expense.vendor_name || ''); - setExpenseDate(expense.expense_date || ''); - setPaidBy(expense.paid_by); - setNotes(expense.notes || ''); - setReceiptUrl(expense.receipt_url); - } else { - resetForm(); - } - }, [expense]); - - function resetForm() { - setCategory('other'); - setDescription(''); - setAmount(''); - setVendorName(''); - setExpenseDate(new Date().toISOString().split('T')[0] || ''); - setPaidBy('team'); - setNotes(''); - setReceiptUrl(null); - setReceiptFile(null); - setError(null); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - - const parsedAmount = parseFloat(amount); - if (!description.trim()) { - setError('Description is required'); - return; - } - if (isNaN(parsedAmount) || parsedAmount <= 0) { - setError('Please enter a valid amount'); - return; - } - - setLoading(true); - - try { - // Upload the receipt first so its URL is persisted with the expense. - // This is the only write path for receipt_url — without it the attached - // file would be silently dropped on save. - let finalReceiptUrl = receiptUrl; - if (receiptFile) { - setUploading(true); - const uploadResult = await uploadExpenseReceipt(receiptFile, teamId, expense?.id); - setUploading(false); - if (!uploadResult.success || !uploadResult.url) { - setError(uploadResult.error || 'Failed to upload receipt'); - return; - } - finalReceiptUrl = uploadResult.url; - } - - if (expense) { - // Update existing expense - const result = await updateTravelExpense({ - id: expense.id, - category, - description: description.trim(), - amount: parsedAmount, - vendor_name: vendorName.trim() || null, - expense_date: expenseDate || null, - paid_by: paidBy, - notes: notes.trim() || null, - receipt_url: finalReceiptUrl, - }); - - if (!result.success) { - setError(result.error || 'Failed to update expense'); - return; - } - } else { - // Create new expense - const input: CreateExpenseInput = { - team_id: teamId, - itinerary_id: itineraryId || null, - category, - description: description.trim(), - amount: parsedAmount, - vendor_name: vendorName.trim() || null, - expense_date: expenseDate || null, - paid_by: paidBy, - notes: notes.trim() || null, - receipt_url: finalReceiptUrl, - }; - - const result = await createTravelExpense(input); - - if (!result.success) { - setError(result.error || 'Failed to create expense'); - return; - } - } - - onSaved(); - onClose(); - resetForm(); - } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - } finally { - setLoading(false); - setUploading(false); - } - } - - function handleFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (file) { - // Validate file type - const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; - if (!validTypes.includes(file.type)) { - setError('Please upload an image (JPG, PNG, WebP) or PDF'); - return; - } - // Validate file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - setError('File size must be less than 5MB'); - return; - } - setReceiptFile(file); - setError(null); - } - } - - function removeReceipt() { - setReceiptFile(null); - setReceiptUrl(null); - } - - return ( - { - if (!next) onClose(); - }} - > - - - {expense ? 'Edit Expense' : 'Add Expense'} - -
- {error && ( - - {error} - - )} - - {/* Category Selection */} -
-

Category

-
- {CATEGORIES.map((cat) => { - const CatIcon = cat.icon; - return ( - setCategory(cat.value)} - whileHover={prefersReducedMotion ? undefined : ({ scale: 1.02 })} - whileTap={prefersReducedMotion ? undefined : ({ scale: 0.98 })} - className={`p-3 rounded-xl border-2 text-left transition-all ${ - category === cat.value - ? 'border-primary-600 bg-primary-50 shadow-sm' - : 'border-warm-200 hover:border-warm-300 hover:shadow-sm' - }`} - > - - {cat.label} - - ); - })} -
-
- - {/* Description and Amount */} -
- setDescription(e.target.value)} - placeholder="Hotel stay, flight tickets..." - required - /> - setAmount(e.target.value)} - placeholder="0.00" - required - /> -
- - {/* Vendor and Date */} -
- setVendorName(e.target.value)} - placeholder="Marriott, Delta Airlines..." - /> - setExpenseDate(e.target.value)} - /> -
- - {/* Paid By */} -
-

Paid By

-
- {PAID_BY_OPTIONS.map((option) => ( - setPaidBy(option.value)} - whileHover={prefersReducedMotion ? undefined : ({ scale: 1.02 })} - whileTap={prefersReducedMotion ? undefined : ({ scale: 0.98 })} - className={`px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${ - paidBy === option.value - ? 'border-primary-600 bg-primary-50 text-primary-700' - : 'border-warm-200 text-warm-600 hover:border-warm-300' - }`} - > - {option.label} - - ))} -
-
- - {/* Receipt Upload */} -
-

Receipt (Optional)

- - {receiptFile || receiptUrl ? ( - -
- -
-
-

- {receiptFile?.name || 'Receipt attached'} -

-

- {receiptFile ? `${(receiptFile.size / 1024).toFixed(1)} KB` : 'Uploaded'} -

-
- - - -
- ) : ( - - - Click to upload receipt - JPG, PNG, WebP or PDF (max 5MB) - - - )} -
-
- - {/* Notes */} -
- -