Add category presentation management

This commit is contained in:
2026-08-13 17:53:05 +09:00
parent 4cd091e2e3
commit 04666d607d
15 changed files with 289 additions and 39 deletions

View File

@@ -28,6 +28,20 @@ docker compose up --build
운영 전 `SESSION_SECRET`과 DB 비밀번호를 반드시 변경하세요.
## 주요 명령
```bash
npm run dev
npm run build
npm run preview
npm run db:generate
npm run db:migrate
npm run db:deploy
npm run db:seed
```
현재 별도 lint/test 스크립트는 아직 없습니다. 빌드 락이 걸린 개발 환경에서는 `NUXT_IGNORE_LOCK=1 npm run build`로 검증합니다.
## 문서
- [요구사항](docs/requirements.md)

View File

@@ -5,11 +5,11 @@
class="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-start gap-3 px-4 py-3 active:bg-paper md:px-5"
>
<div
class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-md bg-paper text-sm font-bold"
:class="transaction.type === 'income' ? 'text-brand-700' : 'text-muted'"
class="mt-0.5 flex h-10 w-10 items-center justify-center rounded-md text-sm font-extrabold"
:style="categoryIconStyle"
aria-hidden="true"
>
{{ categoryInitial }}
{{ categoryIconGlyph }}
</div>
<div class="min-w-0">
@@ -35,6 +35,7 @@
<script setup lang="ts">
import type { Transaction } from '~/composables/useMoney'
import { categoryColor, categoryGlyph } from '~/utils/categoryPresentation'
const props = defineProps<{
transaction: Transaction
@@ -48,10 +49,18 @@ const categoryLabel = computed(() => {
return category.name
})
const categoryInitial = computed(() => {
const presentationCategory = computed(() => {
const category = props.transaction.category
const name = category.parent?.name || category.name || (props.transaction.type === 'income' ? '수' : '지')
return name.slice(0, 1)
return category.icon || category.color ? category : category.parent || category
})
const categoryIconGlyph = computed(() => categoryGlyph(presentationCategory.value.icon))
const categoryIconStyle = computed(() => {
const color = categoryColor(presentationCategory.value.color)
return {
color,
backgroundColor: `${color}14`
}
})
const metaLine = computed(() => {

View File

@@ -15,6 +15,8 @@ export interface Category {
id: string
type: TransactionType
name: string
icon: string
color: string
parentId: string | null
sortOrder: number
active: boolean

View File

@@ -41,3 +41,8 @@
- 공유 링크 생성, 목록 조회, 삭제 API를 추가했다.
- 만료 기간이 있는 공개 읽기 전용 공유 화면을 추가했다.
- 설정 화면에 계정 관리, 로그아웃, 공유 링크 관리 화면을 추가했다.
- 카테고리에 아이콘과 색상 필드를 추가하고 Prisma migration을 추가했다.
- 카테고리 관리 화면에서 상위/하위 목록을 거쳐 카테고리 상세 정보를 수정할 수 있도록 했다.
- 거래 목록의 카테고리 아이콘 자리를 카테고리별 아이콘/색상 설정과 연결했다.
- 거래 입력/수정의 카테고리 선택을 실제 선택 가능한 최종 카테고리 중심으로 정리했다.
- README에 개발, 빌드, Prisma 관련 주요 명령과 현재 검증 기준을 정리했다.

View File

@@ -21,6 +21,7 @@
- 통계 화면은 그래프보다 숫자와 분류별 리스트를 우선한다.
- 방향 이동 UI는 `assets/icons`의 키보드 화살표 SVG를 사용한다.
- 거래 목록의 카테고리 표시는 하위 카테고리가 있으면 하위 이름만, 없으면 상위 이름만 보여준다.
- 카테고리 아이콘과 색상은 카테고리 관리 상세 화면에서 수정하고 거래 목록 아이콘에 즉시 반영한다.
## 데이터 기준
@@ -28,6 +29,7 @@
- Prisma migration은 저장소에 포함한다.
- 자산 잔액은 현재값을 저장하지 않고 거래 합계로 계산한다.
- 카테고리와 자산은 완전 삭제보다 비활성화를 우선한다.
- 거래에 연결되는 카테고리 선택은 하위가 있으면 하위 카테고리를 우선하고, 하위가 없는 경우에만 상위 카테고리를 직접 선택한다.
- CSV Export는 데이터 보존을 위한 필수 기능으로 유지한다.
## 개발/운영 기준

View File

@@ -113,17 +113,21 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
label: asset.name,
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
})))
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
const selectableCategories = computed(() => {
const activeIdsWithChildren = new Set(filteredCategories.value.filter((category: any) => category.parentId).map((category: any) => category.parentId))
return filteredCategories.value.filter((category: any) => !activeIdsWithChildren.has(category.id))
})
const categoryOptions = computed(() => selectableCategories.value.map((category: any) => ({
value: category.id,
label: category.name,
description: category.type === 'income' ? '수입' : '지출'
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
})))
watch(assets, (items: any[]) => {
if (!form.assetId && items[0]) form.assetId = items[0].id
}, { immediate: true })
watch(filteredCategories, (items: any[]) => {
watch(selectableCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
}, { immediate: true })

View File

@@ -155,8 +155,61 @@
</div>
</div>
<div v-else class="py-4">
<div v-if="!selectedParentCategory" class="px-4">
<div v-else-if="view === 'categories'" class="py-4">
<form v-if="selectedCategory" class="px-4" @submit.prevent="saveCategory">
<section class="space-y-5 border-y border-line bg-white px-4 py-4">
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
<label class="text-sm font-semibold text-muted" for="categoryName">이름</label>
<input id="categoryName" v-model="categoryEditForm.name" class="field border-0 bg-transparent font-semibold shadow-none focus:ring-0" required>
</div>
<div class="grid grid-cols-[4.5rem_1fr] items-start gap-3">
<span class="pt-2 text-sm font-semibold text-muted">아이콘</span>
<div class="grid grid-cols-5 gap-2 sm:grid-cols-7">
<button
v-for="option in categoryIconOptions"
:key="option.value"
type="button"
class="flex h-10 items-center justify-center rounded-md border text-sm font-extrabold"
:class="categoryEditForm.icon === option.value ? 'border-brand-700 bg-brand-50 text-brand-700' : 'border-line text-muted'"
@click="categoryEditForm.icon = option.value"
>
{{ option.glyph }}
</button>
</div>
</div>
<div class="grid grid-cols-[4.5rem_1fr] items-start gap-3">
<span class="pt-2 text-sm font-semibold text-muted">색상</span>
<div class="flex flex-wrap gap-2">
<button
v-for="color in categoryColorOptions"
:key="color"
type="button"
class="h-9 w-9 rounded-full border-2"
:class="categoryEditForm.color === color ? 'border-ink' : 'border-transparent'"
:style="{ backgroundColor: color }"
:aria-label="color"
@click="categoryEditForm.color = color"
/>
</div>
</div>
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
<span class="text-sm font-semibold text-muted">유형</span>
<p class="font-semibold">{{ selectedCategory.type === 'income' ? '수입' : '지출' }}</p>
</div>
<div class="grid grid-cols-[4.5rem_1fr] items-center gap-3">
<span class="text-sm font-semibold text-muted">위치</span>
<p class="font-semibold">{{ selectedCategory.parent?.name || '상위 카테고리' }}</p>
</div>
</section>
<button class="btn-primary mt-5 w-full" type="submit">저장하기</button>
</form>
<div v-else-if="!selectedParentCategory" class="px-4">
<div class="segmented">
<button
type="button"
@@ -177,7 +230,28 @@
</div>
</div>
<div class="mt-4 divide-y divide-line border-y border-line bg-white">
<div v-if="!selectedCategory" class="mt-4 divide-y divide-line border-y border-line bg-white">
<button
v-if="selectedParentCategory"
type="button"
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
@click="openCategoryDetail(selectedParentCategory)"
>
<span class="flex min-w-0 items-center gap-3">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-extrabold"
:style="categoryStyle(selectedParentCategory)"
>
{{ categoryIcon(selectedParentCategory) }}
</span>
<span class="min-w-0">
<span class="block truncate text-base font-semibold text-ink">상위 카테고리 설정</span>
<span class="mt-1 block truncate text-xs text-muted">{{ selectedParentCategory.name }}</span>
</span>
</span>
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
</button>
<button
v-for="category in visibleCategories"
:key="category.id"
@@ -185,11 +259,19 @@
class="flex w-full items-center justify-between px-4 py-4 text-left active:bg-paper"
@click="selectCategory(category)"
>
<span>
<span class="block text-base font-semibold text-ink">{{ category.name }}</span>
<span class="mt-1 block text-xs text-muted">{{ categoryDescription(category) }}</span>
<span class="flex min-w-0 items-center gap-3">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-extrabold"
:style="categoryStyle(category)"
>
{{ categoryIcon(category) }}
</span>
<span class="min-w-0">
<span class="block truncate text-base font-semibold text-ink">{{ category.name }}</span>
<span class="mt-1 block truncate text-xs text-muted">{{ categoryDescription(category) }}</span>
</span>
</span>
<img v-if="!selectedParentCategory" class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
<img class="h-6 w-6 opacity-60" src="~/assets/icons/keyboard_arrow_right.svg" alt="">
</button>
<p v-if="!visibleCategories.length" class="px-4 py-12 text-center text-muted">
@@ -226,6 +308,7 @@
<script setup lang="ts">
import { format } from 'date-fns'
import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS, categoryColor, categoryGlyph } from '~/utils/categoryPresentation'
type SettingsView = 'home' | 'account' | 'sharing' | 'assets' | 'categories'
type CategoryType = 'income' | 'expense'
@@ -234,6 +317,7 @@ const view = ref<SettingsView>('home')
const session = useState<{ id: string; email: string; name: string } | null>('session')
const categoryType = ref<CategoryType>('expense')
const selectedParentId = ref('')
const selectedCategoryId = ref('')
const addSheetOpen = ref(false)
const categoryName = ref('')
const { data: assetData, refresh: refreshAssets } = await useFetch('/api/assets', { query: { includeInactive: true } })
@@ -257,11 +341,19 @@ const shareForm = reactive({
name: '',
days: 7
})
const categoryEditForm = reactive({
name: '',
icon: 'circle',
color: '#6b7673'
})
const categoryIconOptions = CATEGORY_ICON_OPTIONS
const categoryColorOptions = CATEGORY_COLOR_OPTIONS
const title = computed(() => {
if (view.value === 'account') return '계정 관리'
if (view.value === 'sharing') return '공유 링크 관리'
if (view.value === 'assets') return '자산 관리'
if (selectedCategory.value) return '카테고리 정보'
if (selectedParentCategory.value) return selectedParentCategory.value.name
if (view.value === 'categories') return '카테고리 관리'
return '설정'
@@ -270,6 +362,7 @@ const assets = computed(() => assetData.value || [])
const categories = computed(() => categoryData.value || [])
const shareLinks = computed(() => shareLinkData.value || [])
const selectedParentCategory = computed(() => categories.value.find((category: any) => category.id === selectedParentId.value))
const selectedCategory = computed(() => categories.value.find((category: any) => category.id === selectedCategoryId.value))
const categoryTypeLabel = computed(() => categoryType.value === 'income' ? '수입' : '지출')
const visibleCategories = computed(() => {
if (selectedParentId.value) {
@@ -287,14 +380,29 @@ const addSheetTitle = computed(() => selectedParentCategory.value ? `${selectedP
watch(categoryType, () => {
selectedParentId.value = ''
selectedCategoryId.value = ''
})
watch(selectedCategory, (category: any) => {
if (!category) return
Object.assign(categoryEditForm, {
name: category.name,
icon: category.icon || 'circle',
color: category.color || '#6b7673'
})
})
function open(nextView: SettingsView) {
view.value = nextView
selectedParentId.value = ''
selectedCategoryId.value = ''
}
function goBack() {
if (selectedCategoryId.value) {
selectedCategoryId.value = ''
return
}
if (selectedParentId.value) {
selectedParentId.value = ''
return
@@ -308,16 +416,35 @@ function openAddSheet() {
}
function selectCategory(category: any) {
if (selectedParentId.value) return
if (selectedParentId.value) {
openCategoryDetail(category)
return
}
selectedParentId.value = category.id
}
function openCategoryDetail(category: any) {
selectedCategoryId.value = category.id
}
function categoryDescription(category: any) {
if (selectedParentId.value) return categoryTypeLabel.value
if (selectedParentId.value) return '하위 카테고리'
const childCount = categories.value.filter((item: any) => item.parentId === category.id).length
return `${childCount}개 하위 카테고리`
}
function categoryIcon(category: any) {
return categoryGlyph(category.icon)
}
function categoryStyle(category: any) {
const color = categoryColor(category.color)
return {
color,
backgroundColor: `${color}14`
}
}
async function createAsset() {
await $fetch('/api/assets', { method: 'POST', body: assetForm })
Object.assign(assetForm, { name: '', balanceType: 'cumulative', initialBalance: 0 })
@@ -335,7 +462,9 @@ async function createCategory() {
body: {
type: categoryType.value,
parentId: selectedParentId.value || '',
name: categoryName.value
name: categoryName.value,
icon: selectedParentCategory.value?.icon || 'circle',
color: selectedParentCategory.value?.color || '#6b7673'
}
})
categoryName.value = ''
@@ -343,6 +472,16 @@ async function createCategory() {
await refreshCategories()
}
async function saveCategory() {
if (!selectedCategory.value) return
await $fetch(`/api/categories/${selectedCategory.value.id}`, {
method: 'PATCH',
body: categoryEditForm
})
await refreshCategories()
selectedCategoryId.value = ''
}
async function createShareLink() {
await $fetch('/api/share-links', {
method: 'POST',

View File

@@ -114,10 +114,14 @@ const assetOptions = computed(() => assets.value.map((asset: any) => ({
label: asset.name,
description: asset.balanceType === 'cumulative' ? '계속 누적' : '매월 새로 시작'
})))
const categoryOptions = computed(() => filteredCategories.value.map((category: any) => ({
const selectableCategories = computed(() => {
const activeIdsWithChildren = new Set(filteredCategories.value.filter((category: any) => category.parentId).map((category: any) => category.parentId))
return filteredCategories.value.filter((category: any) => !activeIdsWithChildren.has(category.id))
})
const categoryOptions = computed(() => selectableCategories.value.map((category: any) => ({
value: category.id,
label: category.name,
description: category.type === 'income' ? '수입' : '지출'
description: category.parent?.name || (category.type === 'income' ? '수입' : '지출')
})))
const locationLabel = computed(() => form.latitude && form.longitude)
const mapsUrl = computed(() => `https://www.google.com/maps?q=${form.latitude},${form.longitude}`)
@@ -137,7 +141,7 @@ watch(transaction, (value) => {
})
}, { immediate: true })
watch(filteredCategories, (items: any[]) => {
watch(selectableCategories, (items: any[]) => {
if (!items.some(item => item.id === form.categoryId)) form.categoryId = items[0]?.id || ''
})

View File

@@ -0,0 +1,3 @@
ALTER TABLE "Category"
ADD COLUMN "icon" TEXT NOT NULL DEFAULT 'circle',
ADD COLUMN "color" TEXT NOT NULL DEFAULT '#6b7673';

View File

@@ -62,6 +62,8 @@ model Category {
userId String
type TransactionType
name String
icon String @default("circle")
color String @default("#6b7673")
parentId String?
sortOrder Int @default(0)
active Boolean @default(true)

View File

@@ -1,19 +1,42 @@
import { readBody, getRouterParam, createError } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS } from '../../../utils/categoryPresentation'
function validIcon(value?: string) {
return CATEGORY_ICON_OPTIONS.some(option => option.value === value)
}
function validColor(value?: string) {
return CATEGORY_COLOR_OPTIONS.includes(value || '')
}
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const id = getRouterParam(event, 'id')!
const body = await readBody<{ name?: string; active?: boolean; sortOrder?: number }>(event)
const body = await readBody<{ name?: string; icon?: string; color?: string; active?: boolean; sortOrder?: number }>(event)
const category = await prisma.category.findFirst({ where: { id, userId: user.id } })
if (!category) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
if (body.name !== undefined && !body.name.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Category name is required' })
}
if (body.icon !== undefined && !validIcon(body.icon)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid category icon' })
}
if (body.color !== undefined && !validColor(body.color)) {
throw createError({ statusCode: 400, statusMessage: 'Invalid category color' })
}
return prisma.category.update({
where: { id },
data: {
...(body.name !== undefined ? { name: body.name.trim() } : {}),
...(body.icon !== undefined ? { icon: body.icon } : {}),
...(body.color !== undefined ? { color: body.color } : {}),
...(body.active !== undefined ? { active: body.active } : {}),
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {})
}

View File

@@ -1,25 +1,46 @@
import { readBody, createError } from 'h3'
import { prisma } from '../../utils/db'
import { requireUser } from '../../utils/auth'
import { CATEGORY_COLOR_OPTIONS, CATEGORY_ICON_OPTIONS } from '../../../utils/categoryPresentation'
function validIcon(value?: string) {
return CATEGORY_ICON_OPTIONS.some(option => option.value === value) ? value : 'circle'
}
function validColor(value?: string) {
return CATEGORY_COLOR_OPTIONS.includes(value || '') ? value : '#6b7673'
}
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const body = await readBody<{ type: 'income' | 'expense'; name: string; parentId?: string }>(event)
const body = await readBody<{ type: 'income' | 'expense'; name: string; parentId?: string; icon?: string; color?: string }>(event)
let type = body.type
if (!body.name?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'Category name is required' })
}
if (!body.parentId && type !== 'income' && type !== 'expense') {
throw createError({ statusCode: 400, statusMessage: 'Invalid category type' })
}
if (body.parentId) {
const parent = await prisma.category.findFirst({ where: { id: body.parentId, userId: user.id } })
if (!parent || parent.parentId) {
throw createError({ statusCode: 400, statusMessage: 'Only two category levels are allowed' })
}
type = parent.type
}
const count = await prisma.category.count({ where: { userId: user.id, type: body.type, parentId: body.parentId || null } })
const count = await prisma.category.count({ where: { userId: user.id, type, parentId: body.parentId || null } })
return prisma.category.create({
data: {
userId: user.id,
type: body.type,
type,
name: body.name.trim(),
icon: validIcon(body.icon),
color: validColor(body.color),
parentId: body.parentId || null,
sortOrder: count
}

View File

@@ -12,24 +12,24 @@ export async function createDefaultMoneyData(prisma: PrismaClient, userId: strin
})
const incomeParent = await prisma.category.create({
data: { userId, type: 'income', name: '근로', sortOrder: 0 }
data: { userId, type: 'income', name: '근로', icon: 'work', color: '#2563eb', sortOrder: 0 }
})
await prisma.category.create({
data: { userId, type: 'income', name: '월급', parentId: incomeParent.id, sortOrder: 0 }
data: { userId, type: 'income', name: '월급', icon: 'salary', color: '#2563eb', parentId: incomeParent.id, sortOrder: 0 }
})
const foodParent = await prisma.category.create({
data: { userId, type: 'expense', name: '식비', sortOrder: 0 }
data: { userId, type: 'expense', name: '식비', icon: 'food', color: '#f97316', sortOrder: 0 }
})
await prisma.category.create({
data: { userId, type: 'expense', name: '외식', parentId: foodParent.id, sortOrder: 0 }
data: { userId, type: 'expense', name: '외식', icon: 'food', color: '#f97316', parentId: foodParent.id, sortOrder: 0 }
})
const livingParent = await prisma.category.create({
data: { userId, type: 'expense', name: '생활', sortOrder: 1 }
data: { userId, type: 'expense', name: '생활', icon: 'shopping', color: '#ef4444', sortOrder: 1 }
})
await prisma.category.create({
data: { userId, type: 'expense', name: '마트', parentId: livingParent.id, sortOrder: 0 }
data: { userId, type: 'expense', name: '마트', icon: 'shopping', color: '#ef4444', parentId: livingParent.id, sortOrder: 0 }
})
return { cash }

View File

@@ -12,7 +12,6 @@
## 2. 내역 화면
- 날짜별 수입/지출 표시가 좁은 화면에서도 겹치지 않도록 최종 조정한다.
- 거래 행 왼쪽 아이콘을 카테고리별 아이콘으로 표시한다.
- 거래 취소/환불 같은 상태 표시 규칙을 정한다.
## 3. 거래 입력/수정
@@ -26,8 +25,6 @@
## 4. 카테고리
- 카테고리별 아이콘 설정 기능을 구현한다.
- 카테고리별 색상 설정 기능을 구현한다.
- 상위 카테고리와 하위 카테고리의 추가, 수정, 삭제 화면을 완성한다.
- 카테고리 정렬 순서 변경 기능을 추가한다.
- 사용 중인 카테고리 삭제 시 처리 규칙을 정한다.
@@ -53,15 +50,12 @@
- 설정 홈의 항목 구성을 다시 정리한다.
- 자산 관리 화면을 추가/수정/삭제가 명확한 상세 구조로 바꾼다.
- 카테고리 관리 화면의 추가/수정 흐름을 더 단순화한다.
- 주 화폐, 월 시작일, 주간 시작일 같은 기본 설정 항목을 구현한다.
- 설정 변경이 실제 화면 계산과 표시 방식에 반영되도록 연결한다.
## 8. 데이터와 API
- 거래 API에서 이체 타입을 지원한다.
- 검색과 필터용 API query를 추가한다.
- 카테고리 아이콘/색상 필드를 Prisma schema에 추가한다.
- 사용자 설정 테이블을 추가한다.
- 거래 목록 조회 성능을 고려해 필요한 index를 점검한다.
- CSV 내보내기 컬럼을 실제 사용 흐름에 맞게 정리한다.
@@ -85,4 +79,3 @@
- 거래 생성, 수정, 삭제 API 테스트를 추가한다.
- 카테고리와 자산 API 테스트를 추가한다.
- 주요 모바일 화면을 Playwright로 스모크 테스트한다.
- 빌드, lint, 테스트 명령을 README에 정리한다.

View File

@@ -0,0 +1,29 @@
export const CATEGORY_ICON_OPTIONS = [
{ value: 'circle', label: '기본', glyph: '●' },
{ value: 'food', label: '식비', glyph: '식' },
{ value: 'shopping', label: '쇼핑', glyph: '쇼' },
{ value: 'home', label: '주거', glyph: '집' },
{ value: 'transport', label: '교통', glyph: '차' },
{ value: 'health', label: '건강', glyph: '건' },
{ value: 'work', label: '근로', glyph: '근' },
{ value: 'salary', label: '급여', glyph: '월' },
{ value: 'gift', label: '기타', glyph: '기' }
]
export const CATEGORY_COLOR_OPTIONS = [
'#ef4444',
'#f97316',
'#eab308',
'#14b8a6',
'#2563eb',
'#8b5cf6',
'#6b7673'
]
export function categoryGlyph(icon?: string | null) {
return CATEGORY_ICON_OPTIONS.find(option => option.value === icon)?.glyph || CATEGORY_ICON_OPTIONS[0].glyph
}
export function categoryColor(color?: string | null) {
return CATEGORY_COLOR_OPTIONS.includes(color || '') ? color! : CATEGORY_COLOR_OPTIONS[6]
}