From d13a331ab6c6ee99a943dcf90551f19f469a9864 Mon Sep 17 00:00:00 2001 From: LogenNineFingersIsAlive Date: Thu, 26 Feb 2026 01:11:30 +0000 Subject: [PATCH 1/2] feat(categories): add grouped searchable icon picker and color selection --- apps/pwa/src/api.ts | 1 + .../features/categories/CategoriesScreen.tsx | 3 + apps/pwa/src/features/categories/constants.ts | 306 ++++++++++++++--- .../categories/dialogs/AddCategoryDialog.tsx | 111 ++++++- .../categories/dialogs/EditCategoryDialog.tsx | 308 +++++++++++------- apps/pwa/src/features/categories/types.ts | 1 + apps/pwa/src/features/categories/utils.ts | 1 + 7 files changed, 561 insertions(+), 170 deletions(-) diff --git a/apps/pwa/src/api.ts b/apps/pwa/src/api.ts index 1c7f707..1600a6c 100644 --- a/apps/pwa/src/api.ts +++ b/apps/pwa/src/api.ts @@ -73,6 +73,7 @@ export const api = { body: { name?: string; icon?: string; + color?: string; reimbursementMode?: 'none' | 'optional' | 'always'; defaultCounterpartyType?: 'self' | 'partner' | 'team' | 'other' | null; defaultRecoveryWindowDays?: number | null; diff --git a/apps/pwa/src/features/categories/CategoriesScreen.tsx b/apps/pwa/src/features/categories/CategoriesScreen.tsx index ee4c191..f66b444 100644 --- a/apps/pwa/src/features/categories/CategoriesScreen.tsx +++ b/apps/pwa/src/features/categories/CategoriesScreen.tsx @@ -100,6 +100,7 @@ export const CategoriesScreen = () => { ...(prev[categoryId] ?? { name: '', icon: 'savings', + color: '#2E7D32', reimbursementMode: 'none', defaultCounterpartyType: null, defaultRecoveryWindowDaysText: '', @@ -117,12 +118,14 @@ export const CategoriesScreen = () => { const patch: { name?: string; icon?: string; + color?: string; reimbursementMode?: 'none' | 'optional' | 'always'; defaultCounterpartyType?: 'self' | 'partner' | 'team' | 'other' | null; defaultRecoveryWindowDays?: number | null; } = { name: draft.name.trim(), icon: draft.icon, + color: draft.color, }; if (category.kind === 'expense') { diff --git a/apps/pwa/src/features/categories/constants.ts b/apps/pwa/src/features/categories/constants.ts index c293014..b879f7a 100644 --- a/apps/pwa/src/features/categories/constants.ts +++ b/apps/pwa/src/features/categories/constants.ts @@ -1,88 +1,304 @@ import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; +import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; +import BakeryDiningIcon from '@mui/icons-material/BakeryDining'; +import BeachAccessIcon from '@mui/icons-material/BeachAccess'; +import BookIcon from '@mui/icons-material/Book'; +import BusinessCenterIcon from '@mui/icons-material/BusinessCenter'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; import CategoryIcon from '@mui/icons-material/Category'; import CelebrationIcon from '@mui/icons-material/Celebration'; +import ChildCareIcon from '@mui/icons-material/ChildCare'; +import CodeIcon from '@mui/icons-material/Code'; +import CreditCardIcon from '@mui/icons-material/CreditCard'; +import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange'; +import DescriptionIcon from '@mui/icons-material/Description'; +import DirectionsBikeIcon from '@mui/icons-material/DirectionsBike'; +import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsCarIcon from '@mui/icons-material/DirectionsCar'; +import DirectionsSubwayIcon from '@mui/icons-material/DirectionsSubway'; +import EngineeringIcon from '@mui/icons-material/Engineering'; +import FastfoodIcon from '@mui/icons-material/Fastfood'; import FavoriteIcon from '@mui/icons-material/Favorite'; +import FitnessCenterIcon from '@mui/icons-material/FitnessCenter'; import FlightIcon from '@mui/icons-material/Flight'; +import HailIcon from '@mui/icons-material/Hail'; +import HealingIcon from '@mui/icons-material/Healing'; +import HikingIcon from '@mui/icons-material/Hiking'; import HomeIcon from '@mui/icons-material/Home'; +import HotelIcon from '@mui/icons-material/Hotel'; import HouseIcon from '@mui/icons-material/House'; +import IcecreamIcon from '@mui/icons-material/Icecream'; +import LaptopMacIcon from '@mui/icons-material/LaptopMac'; +import LocalBarIcon from '@mui/icons-material/LocalBar'; import LocalCafeIcon from '@mui/icons-material/LocalCafe'; +import LocalHospitalIcon from '@mui/icons-material/LocalHospital'; +import LocalPharmacyIcon from '@mui/icons-material/LocalPharmacy'; +import LocalPizzaIcon from '@mui/icons-material/LocalPizza'; +import LuggageIcon from '@mui/icons-material/Luggage'; +import LunchDiningIcon from '@mui/icons-material/LunchDining'; +import MapIcon from '@mui/icons-material/Map'; import MedicalServicesIcon from '@mui/icons-material/MedicalServices'; +import MedicationIcon from '@mui/icons-material/Medication'; import MovieIcon from '@mui/icons-material/Movie'; import MusicNoteIcon from '@mui/icons-material/MusicNote'; +import PaidIcon from '@mui/icons-material/Paid'; import PaymentsIcon from '@mui/icons-material/Payments'; import PetsIcon from '@mui/icons-material/Pets'; +import PodcastsIcon from '@mui/icons-material/Podcasts'; +import PriceCheckIcon from '@mui/icons-material/PriceCheck'; import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; import RestaurantIcon from '@mui/icons-material/Restaurant'; import SavingsIcon from '@mui/icons-material/Savings'; import SchoolIcon from '@mui/icons-material/School'; +import SelfImprovementIcon from '@mui/icons-material/SelfImprovement'; import ShoppingBagIcon from '@mui/icons-material/ShoppingBag'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import SportsEsportsIcon from '@mui/icons-material/SportsEsports'; import SportsSoccerIcon from '@mui/icons-material/SportsSoccer'; +import SubscriptionsIcon from '@mui/icons-material/Subscriptions'; +import TerminalIcon from '@mui/icons-material/Terminal'; import TheatersIcon from '@mui/icons-material/Theaters'; import TrainIcon from '@mui/icons-material/Train'; +import TramIcon from '@mui/icons-material/Tram'; +import TrendingDownIcon from '@mui/icons-material/TrendingDown'; +import TrendingUpIcon from '@mui/icons-material/TrendingUp'; import TvIcon from '@mui/icons-material/Tv'; +import TwoWheelerIcon from '@mui/icons-material/TwoWheeler'; import WorkIcon from '@mui/icons-material/Work'; import type { ElementType } from 'react'; -export const CATEGORY_ICON_OPTIONS = [ - 'savings', - 'home', - 'house', - 'payments', - 'account_balance', - 'shopping_bag', - 'shopping_cart', - 'restaurant', - 'local_cafe', - 'sports_soccer', - 'sports_esports', - 'movie', - 'theaters', - 'music_note', - 'tv', - 'directions_car', - 'train', - 'flight', - 'medical_services', - 'school', - 'work', - 'pets', - 'celebration', - 'favorite', - 'receipt_long', - 'attach_money', - 'category', +export interface CategoryIconOption { + name: string; + label: string; + group: + | 'bills/finance' + | 'transport' + | 'food' + | 'subscriptions' + | 'health' + | 'work' + | 'entertainment' + | 'family/pets' + | 'travel'; + keywords?: string[]; +} + +export const CATEGORY_ICON_OPTIONS: readonly CategoryIconOption[] = [ + { name: 'payments', label: 'Payments', group: 'bills/finance', keywords: ['bill', 'utility'] }, + { name: 'account_balance', label: 'Bank', group: 'bills/finance', keywords: ['bank', 'account'] }, + { + name: 'account_balance_wallet', + label: 'Wallet', + group: 'bills/finance', + keywords: ['wallet', 'cash'], + }, + { name: 'attach_money', label: 'Cash', group: 'bills/finance', keywords: ['money'] }, + { name: 'savings', label: 'Savings', group: 'bills/finance', keywords: ['pot', 'saving'] }, + { name: 'paid', label: 'Paid', group: 'bills/finance', keywords: ['paid', 'salary'] }, + { + name: 'price_check', + label: 'Price check', + group: 'bills/finance', + keywords: ['price', 'receipt'], + }, + { + name: 'currency_exchange', + label: 'Exchange', + group: 'bills/finance', + keywords: ['fx', 'exchange'], + }, + { name: 'credit_card', label: 'Card', group: 'bills/finance', keywords: ['card', 'payment'] }, + { name: 'receipt_long', label: 'Receipt', group: 'bills/finance', keywords: ['invoice', 'bill'] }, + { + name: 'trending_up', + label: 'Investing up', + group: 'bills/finance', + keywords: ['invest', 'growth'], + }, + { + name: 'trending_down', + label: 'Investing down', + group: 'bills/finance', + keywords: ['loss', 'drop'], + }, + + { name: 'directions_car', label: 'Car', group: 'transport', keywords: ['car', 'drive'] }, + { name: 'directions_bus', label: 'Bus', group: 'transport', keywords: ['bus', 'commute'] }, + { name: 'directions_subway', label: 'Subway', group: 'transport', keywords: ['tube', 'metro'] }, + { name: 'train', label: 'Train', group: 'transport', keywords: ['rail'] }, + { name: 'tram', label: 'Tram', group: 'transport', keywords: ['tram'] }, + { name: 'flight', label: 'Flight', group: 'transport', keywords: ['plane', 'air'] }, + { name: 'hail', label: 'Taxi', group: 'transport', keywords: ['cab', 'uber'] }, + { name: 'directions_bike', label: 'Bike', group: 'transport', keywords: ['cycle'] }, + { + name: 'two_wheeler', + label: 'Scooter/Motorbike', + group: 'transport', + keywords: ['motorbike', 'scooter'], + }, + + { name: 'restaurant', label: 'Restaurant', group: 'food', keywords: ['dining', 'meal'] }, + { name: 'local_cafe', label: 'Coffee', group: 'food', keywords: ['cafe', 'coffee'] }, + { name: 'fastfood', label: 'Fast food', group: 'food', keywords: ['takeaway'] }, + { name: 'local_pizza', label: 'Pizza', group: 'food', keywords: ['pizza'] }, + { name: 'lunch_dining', label: 'Lunch', group: 'food', keywords: ['lunch'] }, + { name: 'bakery_dining', label: 'Bakery', group: 'food', keywords: ['bread', 'pastry'] }, + { name: 'icecream', label: 'Dessert', group: 'food', keywords: ['ice cream', 'dessert'] }, + { name: 'local_bar', label: 'Bar', group: 'food', keywords: ['drinks', 'alcohol'] }, + + { + name: 'subscriptions', + label: 'Subscription', + group: 'subscriptions', + keywords: ['monthly', 'renewal'], + }, + { name: 'tv', label: 'TV', group: 'subscriptions', keywords: ['streaming'] }, + { name: 'podcasts', label: 'Podcasts', group: 'subscriptions', keywords: ['audio'] }, + { name: 'book', label: 'Books', group: 'subscriptions', keywords: ['reading', 'kindle'] }, + { name: 'music_note', label: 'Music', group: 'subscriptions', keywords: ['spotify'] }, + + { name: 'medical_services', label: 'Medical', group: 'health', keywords: ['doctor', 'gp'] }, + { name: 'local_hospital', label: 'Hospital', group: 'health', keywords: ['clinic'] }, + { name: 'medication', label: 'Medication', group: 'health', keywords: ['medicine'] }, + { name: 'local_pharmacy', label: 'Pharmacy', group: 'health', keywords: ['prescription'] }, + { name: 'fitness_center', label: 'Fitness', group: 'health', keywords: ['gym'] }, + { name: 'healing', label: 'Therapy', group: 'health', keywords: ['care'] }, + { name: 'self_improvement', label: 'Wellness', group: 'health', keywords: ['mindfulness'] }, + + { name: 'work', label: 'Work', group: 'work', keywords: ['job'] }, + { name: 'business_center', label: 'Business', group: 'work', keywords: ['office'] }, + { name: 'laptop_mac', label: 'Laptop', group: 'work', keywords: ['computer'] }, + { name: 'code', label: 'Code', group: 'work', keywords: ['dev', 'programming'] }, + { name: 'terminal', label: 'Terminal', group: 'work', keywords: ['cli'] }, + { name: 'engineering', label: 'Engineering', group: 'work', keywords: ['build'] }, + { name: 'school', label: 'Learning', group: 'work', keywords: ['course', 'study'] }, + { name: 'description', label: 'Documents', group: 'work', keywords: ['docs', 'paperwork'] }, + + { name: 'movie', label: 'Movies', group: 'entertainment', keywords: ['cinema'] }, + { name: 'theaters', label: 'Theatre', group: 'entertainment', keywords: ['show'] }, + { name: 'sports_esports', label: 'Gaming', group: 'entertainment', keywords: ['games'] }, + { name: 'sports_soccer', label: 'Sports', group: 'entertainment', keywords: ['football'] }, + { name: 'celebration', label: 'Events', group: 'entertainment', keywords: ['party'] }, + + { name: 'home', label: 'Home', group: 'family/pets', keywords: ['household'] }, + { name: 'house', label: 'House', group: 'family/pets', keywords: ['mortgage'] }, + { name: 'pets', label: 'Pets', group: 'family/pets', keywords: ['pet'] }, + { name: 'child_care', label: 'Child care', group: 'family/pets', keywords: ['kids', 'family'] }, + { name: 'favorite', label: 'Family', group: 'family/pets', keywords: ['love'] }, + + { name: 'luggage', label: 'Luggage', group: 'travel', keywords: ['travel', 'trip'] }, + { name: 'hotel', label: 'Hotel', group: 'travel', keywords: ['stay'] }, + { name: 'beach_access', label: 'Beach', group: 'travel', keywords: ['holiday'] }, + { name: 'map', label: 'Map', group: 'travel', keywords: ['route'] }, + { name: 'hiking', label: 'Outdoor', group: 'travel', keywords: ['nature'] }, + { name: 'camera_alt', label: 'Photography', group: 'travel', keywords: ['camera'] }, + + { name: 'shopping_bag', label: 'Shopping bag', group: 'subscriptions', keywords: ['shopping'] }, + { + name: 'shopping_cart', + label: 'Shopping cart', + group: 'subscriptions', + keywords: ['groceries'], + }, + { name: 'category', label: 'Generic category', group: 'bills/finance', keywords: ['default'] }, +] as const; + +export const CATEGORY_COLOR_OPTIONS = [ + '#2E7D32', + '#1976D2', + '#00838F', + '#6A1B9A', + '#AD1457', + '#D81B60', + '#E64A19', + '#F57C00', + '#F9A825', + '#7CB342', + '#43A047', + '#1565C0', + '#5E35B1', + '#37474F', + '#546E7A', + '#455A64', ] as const; export const CATEGORY_ICON_COMPONENTS: Record = { - savings: SavingsIcon, - home: HomeIcon, - house: HouseIcon, payments: PaymentsIcon, account_balance: AccountBalanceIcon, - shopping_bag: ShoppingBagIcon, - shopping_cart: ShoppingCartIcon, - restaurant: RestaurantIcon, - local_cafe: LocalCafeIcon, - sports_soccer: SportsSoccerIcon, - sports_esports: SportsEsportsIcon, - movie: MovieIcon, - theaters: TheatersIcon, - music_note: MusicNoteIcon, - tv: TvIcon, + account_balance_wallet: AccountBalanceWalletIcon, + attach_money: AttachMoneyIcon, + savings: SavingsIcon, + paid: PaidIcon, + price_check: PriceCheckIcon, + currency_exchange: CurrencyExchangeIcon, + credit_card: CreditCardIcon, + receipt_long: ReceiptLongIcon, + trending_up: TrendingUpIcon, + trending_down: TrendingDownIcon, + directions_car: DirectionsCarIcon, + directions_bus: DirectionsBusIcon, + directions_subway: DirectionsSubwayIcon, train: TrainIcon, + tram: TramIcon, flight: FlightIcon, + hail: HailIcon, + directions_bike: DirectionsBikeIcon, + two_wheeler: TwoWheelerIcon, + + restaurant: RestaurantIcon, + local_cafe: LocalCafeIcon, + fastfood: FastfoodIcon, + local_pizza: LocalPizzaIcon, + lunch_dining: LunchDiningIcon, + bakery_dining: BakeryDiningIcon, + icecream: IcecreamIcon, + local_bar: LocalBarIcon, + + subscriptions: SubscriptionsIcon, + tv: TvIcon, + podcasts: PodcastsIcon, + book: BookIcon, + music_note: MusicNoteIcon, + medical_services: MedicalServicesIcon, - school: SchoolIcon, + local_hospital: LocalHospitalIcon, + medication: MedicationIcon, + local_pharmacy: LocalPharmacyIcon, + fitness_center: FitnessCenterIcon, + healing: HealingIcon, + self_improvement: SelfImprovementIcon, + work: WorkIcon, - pets: PetsIcon, + business_center: BusinessCenterIcon, + laptop_mac: LaptopMacIcon, + code: CodeIcon, + terminal: TerminalIcon, + engineering: EngineeringIcon, + school: SchoolIcon, + description: DescriptionIcon, + + movie: MovieIcon, + theaters: TheatersIcon, + sports_esports: SportsEsportsIcon, + sports_soccer: SportsSoccerIcon, celebration: CelebrationIcon, + + home: HomeIcon, + house: HouseIcon, + pets: PetsIcon, + child_care: ChildCareIcon, favorite: FavoriteIcon, - receipt_long: ReceiptLongIcon, - attach_money: AttachMoneyIcon, + + luggage: LuggageIcon, + hotel: HotelIcon, + beach_access: BeachAccessIcon, + map: MapIcon, + hiking: HikingIcon, + camera_alt: CameraAltIcon, + + shopping_bag: ShoppingBagIcon, + shopping_cart: ShoppingCartIcon, category: CategoryIcon, }; diff --git a/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx b/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx index 271145d..36a22a0 100644 --- a/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx +++ b/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx @@ -1,6 +1,9 @@ import CategoryIcon from '@mui/icons-material/Category'; import { Alert, + Autocomplete, + Avatar, + Box, Button, Dialog, DialogActions, @@ -10,10 +13,17 @@ import { Stack, TextField, Typography, + useMediaQuery, } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; import { useState } from 'react'; -import { CATEGORY_ICON_COMPONENTS, CATEGORY_ICON_OPTIONS } from '../constants.js'; +import { + CATEGORY_COLOR_OPTIONS, + CATEGORY_ICON_COMPONENTS, + CATEGORY_ICON_OPTIONS, + type CategoryIconOption, +} from '../constants.js'; import { useCreateCategoryMutation } from '../hooks/useCategoriesMutations.js'; import type { CategoryKind } from '../types.js'; import { getErrorMessage, parseNullableNonNegativeInt } from '../utils.js'; @@ -25,6 +35,8 @@ interface AddCategoryDialogProps { export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => { const createCategory = useCreateCategoryMutation(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const [name, setName] = useState(''); const [kind, setKind] = useState('expense'); @@ -32,6 +44,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => 'none', ); const [icon, setIcon] = useState('savings'); + const [color, setColor] = useState('#2E7D32'); const [defaultCounterpartyType, setDefaultCounterpartyType] = useState< 'self' | 'partner' | 'team' | 'other' | null >(null); @@ -43,6 +56,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => setKind('expense'); setReimbursementMode('none'); setIcon('savings'); + setColor('#2E7D32'); setDefaultCounterpartyType(null); setDefaultRecoveryWindowDaysText(''); setSubmitError(null); @@ -62,6 +76,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => name: name.trim(), kind, icon, + color, ...(kind === 'expense' ? { reimbursementMode, @@ -117,24 +132,92 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => Income Transfer - setIcon(event.target.value)} - > - {CATEGORY_ICON_OPTIONS.map((iconName) => { - const IconComponent = CATEGORY_ICON_COMPONENTS[iconName] ?? CategoryIcon; + + options={CATEGORY_ICON_OPTIONS as CategoryIconOption[]} + value={CATEGORY_ICON_OPTIONS.find((option) => option.name === icon) ?? null} + onChange={(_event, option) => setIcon(option?.name ?? 'savings')} + groupBy={(option) => option.group} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => option.name === value.name} + openOnFocus={!isMobile} + ListboxProps={{ style: { maxHeight: isMobile ? 260 : 360 } }} + filterOptions={(options, state) => { + const query = state.inputValue.trim().toLowerCase(); + if (!query) return options; + return options.filter((option) => + [option.label, option.name, ...(option.keywords ?? [])] + .join(' ') + .toLowerCase() + .includes(query), + ); + }} + renderOption={(props, option) => { + const IconComponent = CATEGORY_ICON_COMPONENTS[option.name] ?? CategoryIcon; return ( - +
  • - {iconName} + {option.label} - +
  • ); - })} -
    + }} + renderInput={(params) => ( + + )} + /> + + + + Color + + + {CATEGORY_COLOR_OPTIONS.map((option) => ( + setColor(option)} + sx={{ + width: 28, + height: 28, + borderRadius: '50%', + border: color === option ? '2px solid #111827' : '1px solid #CFD8DC', + backgroundColor: option, + cursor: 'pointer', + }} + /> + ))} + + + + + {(() => { + const IconComponent = CATEGORY_ICON_COMPONENTS[icon] ?? CategoryIcon; + return ( + + + + ); + })()} + + {name.trim() || 'Category preview'} + + {kind === 'expense' ? ( ; errorMessage: string | null; isSubmitting: boolean; @@ -40,114 +46,194 @@ export const EditCategoryDialog = ({ onClose, onSave, onChangeDraft, -}: EditCategoryDialogProps) => ( - - {category && draft ? ( - <> - Edit category - - - onChangeDraft({ name: event.target.value })} - size="small" - autoFocus - /> - onChangeDraft({ icon: event.target.value })} - size="small" - > - {iconOptions.map((iconName) => { - const IconComponent = iconComponents[iconName] ?? CategoryIcon; - return ( - - - - {iconName} - - - ); - })} - - {category.kind === 'expense' ? ( - <> - { - const nextMode = event.target.value as CategoryEditDraft['reimbursementMode']; - onChangeDraft({ - reimbursementMode: nextMode, - ...(nextMode === 'none' - ? { - defaultCounterpartyType: null, - defaultRecoveryWindowDaysText: '', - } - : {}), - }); - }} - size="small" - > - None - Optional - Always - - {draft.reimbursementMode !== 'none' ? ( - <> - - onChangeDraft({ - defaultCounterpartyType: - event.target.value === '__none' - ? null - : (event.target - .value as CategoryEditDraft['defaultCounterpartyType']), - }) - } - size="small" - > - None - Self - Partner - Team - Other - - - onChangeDraft({ defaultRecoveryWindowDaysText: event.target.value }) - } - inputProps={{ min: 0 }} +}: EditCategoryDialogProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + return ( + + {category && draft ? ( + <> + Edit category + + + onChangeDraft({ name: event.target.value })} + size="small" + autoFocus + /> + + options={iconOptions as CategoryIconOption[]} + value={iconOptions.find((option) => option.name === draft.icon) ?? null} + onChange={(_event, option) => onChangeDraft({ icon: option?.name ?? 'savings' })} + groupBy={(option) => option.group} + getOptionLabel={(option) => option.label} + isOptionEqualToValue={(option, value) => option.name === value.name} + openOnFocus={!isMobile} + ListboxProps={{ style: { maxHeight: isMobile ? 260 : 360 } }} + filterOptions={(options, state) => { + const query = state.inputValue.trim().toLowerCase(); + if (!query) return options; + return options.filter((option) => + [option.label, option.name, ...(option.keywords ?? [])] + .join(' ') + .toLowerCase() + .includes(query), + ); + }} + renderOption={(props, option) => { + const IconComponent = iconComponents[option.name] ?? CategoryIcon; + return ( +
  • + + + {option.label} + +
  • + ); + }} + renderInput={(params) => ( + + )} + /> + + + + Color + + + {CATEGORY_COLOR_OPTIONS.map((option) => ( + onChangeDraft({ color: option })} + sx={{ + width: 28, + height: 28, + borderRadius: '50%', + border: draft.color === option ? '2px solid #111827' : '1px solid #CFD8DC', + backgroundColor: option, + cursor: 'pointer', + }} /> - - ) : null} - - ) : null} - {errorMessage ? {errorMessage} : null} - -
    - - - - - - ) : null} -
    -); + ))} +
    + + + + {(() => { + const IconComponent = iconComponents[draft.icon] ?? CategoryIcon; + return ( + + + + ); + })()} + + {draft.name.trim() || 'Category preview'} + + + {category.kind === 'expense' ? ( + <> + { + const nextMode = event.target.value as CategoryEditDraft['reimbursementMode']; + onChangeDraft({ + reimbursementMode: nextMode, + ...(nextMode === 'none' + ? { + defaultCounterpartyType: null, + defaultRecoveryWindowDaysText: '', + } + : {}), + }); + }} + size="small" + > + None + Optional + Always + + {draft.reimbursementMode !== 'none' ? ( + <> + + onChangeDraft({ + defaultCounterpartyType: + event.target.value === '__none' + ? null + : (event.target + .value as CategoryEditDraft['defaultCounterpartyType']), + }) + } + size="small" + > + None + Self + Partner + Team + Other + + + onChangeDraft({ defaultRecoveryWindowDaysText: event.target.value }) + } + inputProps={{ min: 0 }} + /> + + ) : null} + + ) : null} + {errorMessage ? {errorMessage} : null} + +
    + + + + + + ) : null} +
    + ); +}; diff --git a/apps/pwa/src/features/categories/types.ts b/apps/pwa/src/features/categories/types.ts index 1bc935b..2f7945d 100644 --- a/apps/pwa/src/features/categories/types.ts +++ b/apps/pwa/src/features/categories/types.ts @@ -5,6 +5,7 @@ export type CategoryKind = Category['kind']; export interface CategoryEditDraft { name: string; icon: string; + color: string; reimbursementMode: 'none' | 'optional' | 'always'; defaultCounterpartyType: 'self' | 'partner' | 'team' | 'other' | null; defaultRecoveryWindowDaysText: string; diff --git a/apps/pwa/src/features/categories/utils.ts b/apps/pwa/src/features/categories/utils.ts index 0eb85d7..665cf0f 100644 --- a/apps/pwa/src/features/categories/utils.ts +++ b/apps/pwa/src/features/categories/utils.ts @@ -10,6 +10,7 @@ export const isMonzoPlaceholderCategoryName = (name: string): boolean => export const buildDraftFromCategory = (category: Category): CategoryEditDraft => ({ name: category.name, icon: category.icon, + color: category.color, reimbursementMode: category.reimbursementMode ?? 'none', defaultCounterpartyType: category.defaultCounterpartyType ?? null, defaultRecoveryWindowDaysText: From 3a365377d9f0230726bb7150b1cdd1666f9883e0 Mon Sep 17 00:00:00 2001 From: LogenNineFingersIsAlive Date: Thu, 26 Feb 2026 16:38:37 +0000 Subject: [PATCH 2/2] refactor(categories): address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract DEFAULT_CATEGORY_COLOR constant (no more hardcoded duplication) - use theme palette for swatch borders instead of hardcoded hex - add aria-label on color swatch buttons for accessibility - remove unnecessary 'as CategoryIconOption[]' type casts - remove IIFE pattern from preview icon rendering - bump avatar background alpha from 22 to 33 (13% → 20% opacity) - move shopping_bag/shopping_cart from subscriptions to new shopping group --- .../features/categories/CategoriesScreen.tsx | 8 +++-- apps/pwa/src/features/categories/constants.ts | 12 +++---- .../categories/dialogs/AddCategoryDialog.tsx | 27 +++++++------- .../categories/dialogs/EditCategoryDialog.tsx | 36 ++++++++++--------- 4 files changed, 45 insertions(+), 38 deletions(-) diff --git a/apps/pwa/src/features/categories/CategoriesScreen.tsx b/apps/pwa/src/features/categories/CategoriesScreen.tsx index f66b444..bb102de 100644 --- a/apps/pwa/src/features/categories/CategoriesScreen.tsx +++ b/apps/pwa/src/features/categories/CategoriesScreen.tsx @@ -4,7 +4,11 @@ import { useMemo, useState } from 'react'; import type { Category, ReimbursementCategoryRule } from '../../types.js'; import { CategoriesListCard } from './components/CategoriesListCard.js'; -import { CATEGORY_ICON_COMPONENTS, CATEGORY_ICON_OPTIONS } from './constants.js'; +import { + CATEGORY_ICON_COMPONENTS, + CATEGORY_ICON_OPTIONS, + DEFAULT_CATEGORY_COLOR, +} from './constants.js'; import { AddCategoryDialog } from './dialogs/AddCategoryDialog.js'; import { AutoMatchRepaymentCategoriesDialog } from './dialogs/AutoMatchRepaymentCategoriesDialog.js'; import { EditCategoryDialog } from './dialogs/EditCategoryDialog.js'; @@ -100,7 +104,7 @@ export const CategoriesScreen = () => { ...(prev[categoryId] ?? { name: '', icon: 'savings', - color: '#2E7D32', + color: DEFAULT_CATEGORY_COLOR, reimbursementMode: 'none', defaultCounterpartyType: null, defaultRecoveryWindowDaysText: '', diff --git a/apps/pwa/src/features/categories/constants.ts b/apps/pwa/src/features/categories/constants.ts index b879f7a..3c88d8a 100644 --- a/apps/pwa/src/features/categories/constants.ts +++ b/apps/pwa/src/features/categories/constants.ts @@ -68,6 +68,8 @@ import TwoWheelerIcon from '@mui/icons-material/TwoWheeler'; import WorkIcon from '@mui/icons-material/Work'; import type { ElementType } from 'react'; +export const DEFAULT_CATEGORY_COLOR = '#2E7D32'; + export interface CategoryIconOption { name: string; label: string; @@ -75,6 +77,7 @@ export interface CategoryIconOption { | 'bills/finance' | 'transport' | 'food' + | 'shopping' | 'subscriptions' | 'health' | 'work' @@ -194,13 +197,8 @@ export const CATEGORY_ICON_OPTIONS: readonly CategoryIconOption[] = [ { name: 'hiking', label: 'Outdoor', group: 'travel', keywords: ['nature'] }, { name: 'camera_alt', label: 'Photography', group: 'travel', keywords: ['camera'] }, - { name: 'shopping_bag', label: 'Shopping bag', group: 'subscriptions', keywords: ['shopping'] }, - { - name: 'shopping_cart', - label: 'Shopping cart', - group: 'subscriptions', - keywords: ['groceries'], - }, + { name: 'shopping_bag', label: 'Shopping bag', group: 'shopping', keywords: ['shopping'] }, + { name: 'shopping_cart', label: 'Shopping cart', group: 'shopping', keywords: ['groceries'] }, { name: 'category', label: 'Generic category', group: 'bills/finance', keywords: ['default'] }, ] as const; diff --git a/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx b/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx index 36a22a0..3e6e5d5 100644 --- a/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx +++ b/apps/pwa/src/features/categories/dialogs/AddCategoryDialog.tsx @@ -23,6 +23,7 @@ import { CATEGORY_ICON_COMPONENTS, CATEGORY_ICON_OPTIONS, type CategoryIconOption, + DEFAULT_CATEGORY_COLOR, } from '../constants.js'; import { useCreateCategoryMutation } from '../hooks/useCategoriesMutations.js'; import type { CategoryKind } from '../types.js'; @@ -44,7 +45,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => 'none', ); const [icon, setIcon] = useState('savings'); - const [color, setColor] = useState('#2E7D32'); + const [color, setColor] = useState(DEFAULT_CATEGORY_COLOR); const [defaultCounterpartyType, setDefaultCounterpartyType] = useState< 'self' | 'partner' | 'team' | 'other' | null >(null); @@ -56,7 +57,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => setKind('expense'); setReimbursementMode('none'); setIcon('savings'); - setColor('#2E7D32'); + setColor(DEFAULT_CATEGORY_COLOR); setDefaultCounterpartyType(null); setDefaultRecoveryWindowDaysText(''); setSubmitError(null); @@ -133,7 +134,7 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => Transfer
    - options={CATEGORY_ICON_OPTIONS as CategoryIconOption[]} + options={CATEGORY_ICON_OPTIONS} value={CATEGORY_ICON_OPTIONS.find((option) => option.name === icon) ?? null} onChange={(_event, option) => setIcon(option?.name ?? 'savings')} groupBy={(option) => option.group} @@ -182,12 +183,16 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => key={option} component="button" type="button" + aria-label={`Color ${option}`} onClick={() => setColor(option)} sx={{ width: 28, height: 28, borderRadius: '50%', - border: color === option ? '2px solid #111827' : '1px solid #CFD8DC', + border: + color === option + ? `2px solid ${theme.palette.grey[900]}` + : `1px solid ${theme.palette.divider}`, backgroundColor: option, cursor: 'pointer', }} @@ -206,14 +211,12 @@ export const AddCategoryDialog = ({ open, onClose }: AddCategoryDialogProps) => bgcolor: 'action.hover', }} > - {(() => { - const IconComponent = CATEGORY_ICON_COMPONENTS[icon] ?? CategoryIcon; - return ( - - - - ); - })()} + + {(() => { + const PreviewIcon = CATEGORY_ICON_COMPONENTS[icon] ?? CategoryIcon; + return ; + })()} + {name.trim() || 'Category preview'} diff --git a/apps/pwa/src/features/categories/dialogs/EditCategoryDialog.tsx b/apps/pwa/src/features/categories/dialogs/EditCategoryDialog.tsx index 7b2bcc7..cd662ab 100644 --- a/apps/pwa/src/features/categories/dialogs/EditCategoryDialog.tsx +++ b/apps/pwa/src/features/categories/dialogs/EditCategoryDialog.tsx @@ -65,7 +65,7 @@ export const EditCategoryDialog = ({ autoFocus /> - options={iconOptions as CategoryIconOption[]} + options={iconOptions} value={iconOptions.find((option) => option.name === draft.icon) ?? null} onChange={(_event, option) => onChangeDraft({ icon: option?.name ?? 'savings' })} groupBy={(option) => option.group} @@ -115,12 +115,16 @@ export const EditCategoryDialog = ({ key={option} component="button" type="button" + aria-label={`Color ${option}`} onClick={() => onChangeDraft({ color: option })} sx={{ width: 28, height: 28, borderRadius: '50%', - border: draft.color === option ? '2px solid #111827' : '1px solid #CFD8DC', + border: + draft.color === option + ? `2px solid ${theme.palette.grey[900]}` + : `1px solid ${theme.palette.divider}`, backgroundColor: option, cursor: 'pointer', }} @@ -139,21 +143,19 @@ export const EditCategoryDialog = ({ bgcolor: 'action.hover', }} > - {(() => { - const IconComponent = iconComponents[draft.icon] ?? CategoryIcon; - return ( - - - - ); - })()} + + {(() => { + const PreviewIcon = iconComponents[draft.icon] ?? CategoryIcon; + return ; + })()} + {draft.name.trim() || 'Category preview'}