feat: 개인 일정 캘린더 추가 및 사내 일정 수정 권한 제한
카테고리를 사내 공용(owner=null)과 개인 전용(owner=사용자)으로 나누고, 일정의 공개 범위를 소속 카테고리에서 파생시켜 두 종류가 섞이지 않게 한다. - 조회: 사내 일정 + 본인 개인 일정만 반환, 타인의 개인 항목은 존재를 숨김 - 개인 일정은 소유자만 수정·삭제하며 참석자·알림을 사용하지 않음 - 사내 일정은 등록자만 수정·삭제하도록 제한 (등록자가 탈퇴한 기존 일정은 잠기지 않도록 예외 처리) - 일정이 남은 사내 카테고리는 삭제를 막아 타인 일정 연쇄 삭제를 방지 - 툴바를 캘린더 선택(사내/개인 체크박스)과 '내 관련' 참석자 필터로 분리 - 일정·카테고리 작성 시 사내/개인 구분을 명시적으로 선택 - 기존 데이터는 전부 사내 공유(company)로 유지되는 마이그레이션 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,11 +52,18 @@ const isEdit = computed(() => !!props.init)
|
||||
const name = ref(props.init?.label ?? '')
|
||||
const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210)
|
||||
const color = computed(() => hslToHex(hue.value, CAT_SAT, CAT_LIG))
|
||||
// 개인 전용 여부 — 생성 시에만 선택할 수 있다(수정으로 공용↔개인 전환 불가)
|
||||
const personal = ref(props.init?.personal ?? false)
|
||||
const valid = computed(() => !!name.value.trim())
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
emit('save', { label: name.value.trim(), color: color.value })
|
||||
const payload: ScheduleCategoryPayload = {
|
||||
label: name.value.trim(),
|
||||
color: color.value,
|
||||
}
|
||||
if (!isEdit.value) payload.personal = personal.value
|
||||
emit('save', payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,6 +82,46 @@ function submit(): void {
|
||||
@keydown.enter="submit"
|
||||
>
|
||||
</label>
|
||||
<!-- 구분 — 생성 시에만 선택 가능. 수정 화면에서는 현재 범위를 안내만 한다.
|
||||
(이후 이 카테고리에 담기는 일정의 공개 범위가 여기서 결정되므로 색상보다 위에 둔다) -->
|
||||
<div class="form-field">
|
||||
<span class="form-label"><span class="req">*</span> 구분</span>
|
||||
<div
|
||||
v-if="isEdit"
|
||||
class="scope-note"
|
||||
>
|
||||
{{ personal ? '개인 전용 카테고리입니다. (나만 볼 수 있음)' : '사내 공용 카테고리입니다.' }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="scope-opts">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: !personal }"
|
||||
:aria-pressed="!personal"
|
||||
@click="personal = false"
|
||||
>
|
||||
사내 공용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: personal }"
|
||||
:aria-pressed="personal"
|
||||
@click="personal = true"
|
||||
>
|
||||
개인 전용
|
||||
</button>
|
||||
</div>
|
||||
<p class="scope-hint">
|
||||
{{
|
||||
personal
|
||||
? '이 카테고리와 여기에 등록한 일정은 나만 볼 수 있습니다.'
|
||||
: '이 카테고리의 일정은 사내 모든 구성원에게 공개됩니다.'
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<span class="form-label">색상</span>
|
||||
<div class="cat-color">
|
||||
@@ -117,6 +164,41 @@ function submit(): void {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
/* 공개 범위 선택 — 2분할 세그먼트 */
|
||||
.scope-opts {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.scope-opt {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-opt:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.scope-opt.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.scope-hint {
|
||||
margin: 0.4375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.cat-dot {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
|
||||
@@ -34,6 +34,9 @@ const dur = computed(() =>
|
||||
)
|
||||
|
||||
const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
const isPersonal = computed(() => ev.value?.visibility === 'private')
|
||||
// 수정·삭제 노출 기준 — 서버가 내려준 권한을 그대로 따른다.
|
||||
const canManage = computed(() => !!ev.value?.canManage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,15 +47,37 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
<template v-if="ev">
|
||||
<div class="dt-head">
|
||||
<div class="dt-top">
|
||||
<span
|
||||
v-if="category"
|
||||
class="type-chip"
|
||||
:style="{
|
||||
background: `color-mix(in srgb, ${category.color} 13%, #fff)`,
|
||||
color: category.color,
|
||||
}"
|
||||
>{{ category.label }}</span>
|
||||
<span v-else />
|
||||
<div class="dt-chips">
|
||||
<span
|
||||
v-if="category"
|
||||
class="type-chip"
|
||||
:style="{
|
||||
background: `color-mix(in srgb, ${category.color} 13%, #fff)`,
|
||||
color: category.color,
|
||||
}"
|
||||
>{{ category.label }}</span>
|
||||
<span
|
||||
v-if="isPersonal"
|
||||
class="private-chip"
|
||||
title="나만 볼 수 있는 개인 일정"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
개인
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="dt-close"
|
||||
@click="emit('close')"
|
||||
@@ -124,7 +149,11 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dt-foot">
|
||||
<!-- 수정·삭제는 권한이 있을 때만 노출(개인 일정=소유자, 전사 일정=등록자) -->
|
||||
<div
|
||||
v-if="canManage"
|
||||
class="dt-foot"
|
||||
>
|
||||
<div class="dt-actions">
|
||||
<button
|
||||
class="btn"
|
||||
@@ -179,6 +208,30 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
.detail.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.dt-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 개인 일정 배지 */
|
||||
.private-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: #eef0f4;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.private-chip svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.type-chip {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
people: ApiSchedulePerson[]
|
||||
init: ApiScheduleEvent | null
|
||||
defaultDate: string
|
||||
// 신규 작성 시 기본 구분 — 현재 보고 있는 캘린더를 따른다
|
||||
defaultScope?: 'company' | 'personal'
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', payload: ScheduleEventPayload): void
|
||||
@@ -43,9 +45,34 @@ const pt = parseTime(i?.time ?? '')
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
// 카테고리 선택 그룹 — 공개 범위는 카테고리에서 결정된다.
|
||||
const companyCats = computed(() => ordered.value.filter((c) => !c.personal))
|
||||
const personalCats = computed(() => ordered.value.filter((c) => c.personal))
|
||||
|
||||
// 구분 선택 — 이걸 고르면 아래 유형 목록이 해당 범위로 좁혀진다.
|
||||
// 수정 시에는 기존 일정의 범위, 신규는 defaultScope(카테고리가 없으면 있는 쪽)로 시작한다.
|
||||
function initialScope(): 'company' | 'personal' {
|
||||
if (i) return i.visibility === 'private' ? 'personal' : 'company'
|
||||
const pref = props.defaultScope ?? 'company'
|
||||
const prefHasCats =
|
||||
pref === 'personal' ? personalCats.value.length : companyCats.value.length
|
||||
if (prefHasCats) return pref
|
||||
return companyCats.value.length ? 'company' : 'personal'
|
||||
}
|
||||
const scope = ref<'company' | 'personal'>(initialScope())
|
||||
const scopedCats = computed(() =>
|
||||
scope.value === 'personal' ? personalCats.value : companyCats.value,
|
||||
)
|
||||
|
||||
const title = ref(i?.title ?? '')
|
||||
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
|
||||
const categoryId = ref(i?.categoryId ?? scopedCats.value[0]?.id ?? '')
|
||||
|
||||
// 범위를 바꾸면 선택된 유형이 그 범위 밖일 수 있으므로 첫 항목으로 되돌린다.
|
||||
watch(scope, () => {
|
||||
if (!scopedCats.value.some((c) => c.id === categoryId.value)) {
|
||||
categoryId.value = scopedCats.value[0]?.id ?? ''
|
||||
}
|
||||
})
|
||||
const s = ref(i?.start ?? props.defaultDate)
|
||||
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
|
||||
const startT = ref(pt.start)
|
||||
@@ -60,6 +87,9 @@ watch(s, (v) => {
|
||||
if (e.value < v) e.value = v
|
||||
})
|
||||
|
||||
// 개인 범위 = 개인 일정 — 참석자 개념이 없다.
|
||||
const isPersonal = computed(() => scope.value === 'personal')
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
!!title.value.trim() &&
|
||||
@@ -68,8 +98,8 @@ const valid = computed(
|
||||
!!e.value &&
|
||||
e.value >= s.value &&
|
||||
!!startT.value &&
|
||||
// 참석자 필수 — 전 직원 또는 1명 이상 선택
|
||||
(allHands.value || attendeeIds.value.length > 0),
|
||||
// 참석자 필수 — 전 직원 또는 1명 이상 선택(개인 일정은 해당 없음)
|
||||
(isPersonal.value || allHands.value || attendeeIds.value.length > 0),
|
||||
)
|
||||
|
||||
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
|
||||
@@ -124,8 +154,9 @@ function submit(): void {
|
||||
time,
|
||||
place: place.value.trim(),
|
||||
description: desc.value.trim(),
|
||||
allHands: allHands.value,
|
||||
attendeeIds: allHands.value ? [] : attendeeIds.value,
|
||||
// 개인 일정은 서버에서도 비워지지만, 요청 단계에서 먼저 정리해 보낸다.
|
||||
allHands: isPersonal.value ? false : allHands.value,
|
||||
attendeeIds: isPersonal.value || allHands.value ? [] : attendeeIds.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -143,14 +174,38 @@ function submit(): void {
|
||||
autofocus
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<span class="form-label"><span class="req">*</span> 구분</span>
|
||||
<div class="scope-opts">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: scope === 'company' }"
|
||||
:aria-pressed="scope === 'company'"
|
||||
@click="scope = 'company'"
|
||||
>
|
||||
사내 일정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: scope === 'personal' }"
|
||||
:aria-pressed="scope === 'personal'"
|
||||
@click="scope = 'personal'"
|
||||
>
|
||||
개인 일정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> 유형</span>
|
||||
<div class="form-select-wrap">
|
||||
<select
|
||||
v-model="categoryId"
|
||||
class="form-select"
|
||||
:disabled="scopedCats.length === 0"
|
||||
>
|
||||
<option
|
||||
v-for="c in ordered"
|
||||
v-for="c in scopedCats"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
@@ -168,6 +223,12 @@ function submit(): void {
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="scopedCats.length === 0"
|
||||
class="scope-hint"
|
||||
>
|
||||
{{ scope === 'personal' ? '개인' : '사내' }} 카테고리가 없습니다. 상단 '유형' 메뉴에서 먼저 카테고리를 추가해 주세요.
|
||||
</p>
|
||||
</label>
|
||||
<div class="form-row">
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> 시작일</span>
|
||||
@@ -209,7 +270,31 @@ function submit(): void {
|
||||
placeholder="장소를 입력하세요"
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<!-- 개인 일정(개인 카테고리)은 나만 보는 일정이라 참석자를 두지 않는다 -->
|
||||
<div
|
||||
v-if="isPersonal"
|
||||
class="private-note"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
개인 일정입니다. 나만 볼 수 있으며 참석자와 알림은 사용되지 않습니다.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="form-field"
|
||||
>
|
||||
<span class="form-label"><span class="req">*</span> 참석자</span>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
@@ -330,6 +415,58 @@ function submit(): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 구분(사내/개인) 선택 — 카테고리 모달의 공개 범위 선택과 동일 외형 */
|
||||
.scope-opts {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.scope-opt {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-opt:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.scope-opt.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.scope-hint {
|
||||
margin: 0.4375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 개인 일정 안내 — 참석자 영역을 대체 */
|
||||
.private-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.private-note svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 시간 입력 행 */
|
||||
.time-row {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 유형 필터 + 카테고리 관리 드롭다운
|
||||
// 유형 필터 + 카테고리 관리 드롭다운 — 사내 공용/내 개인 카테고리를 그룹으로 나눠 표시.
|
||||
// 상위 캘린더 스위치가 꺼진 그룹은 빈 배열로 전달되어 목록에서 통째로 빠진다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import type { ApiScheduleCategory } from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: ApiScheduleCategory[]
|
||||
companyCategories: ApiScheduleCategory[]
|
||||
personalCategories: ApiScheduleCategory[]
|
||||
counts: Record<string, number>
|
||||
activeTypeIds: Set<string>
|
||||
}>()
|
||||
@@ -21,13 +23,20 @@ const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
|
||||
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
// 그룹 렌더링용 — 비어 있는 그룹은 헤더째 숨긴다.
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{ key: 'company', label: '사내', items: props.companyCategories },
|
||||
{ key: 'personal', label: '내 카테고리', items: props.personalCategories },
|
||||
].filter((g) => g.items.length > 0),
|
||||
)
|
||||
const all = computed(() => [
|
||||
...props.companyCategories,
|
||||
...props.personalCategories,
|
||||
])
|
||||
const allOn = computed(
|
||||
() =>
|
||||
ordered.value.length > 0 &&
|
||||
ordered.value.every((c) => props.activeTypeIds.has(c.id)),
|
||||
all.value.length > 0 && all.value.every((c) => props.activeTypeIds.has(c.id)),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -77,65 +86,73 @@ const allOn = computed(
|
||||
</button>
|
||||
</div>
|
||||
<div class="fm-list">
|
||||
<div
|
||||
v-for="c in ordered"
|
||||
:key="c.id"
|
||||
class="fm-item"
|
||||
:class="{ on: activeTypeIds.has(c.id) }"
|
||||
<template
|
||||
v-for="g in groups"
|
||||
:key="g.key"
|
||||
>
|
||||
<button
|
||||
class="fm-tog"
|
||||
@click="emit('toggle', c.id)"
|
||||
<div class="fm-group">
|
||||
{{ g.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="c in g.items"
|
||||
:key="c.id"
|
||||
class="fm-item"
|
||||
:class="{ on: activeTypeIds.has(c.id) }"
|
||||
>
|
||||
<span class="fm-check">
|
||||
<svg
|
||||
v-if="activeTypeIds.has(c.id)"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
<button
|
||||
class="fm-tog"
|
||||
@click="emit('toggle', c.id)"
|
||||
>
|
||||
<span class="fm-check">
|
||||
<svg
|
||||
v-if="activeTypeIds.has(c.id)"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: c.color }"
|
||||
/>
|
||||
<span class="fm-label">{{ c.label }}</span>
|
||||
<span class="fm-ct">{{ counts[c.id] || 0 }}</span>
|
||||
</button>
|
||||
<span class="fm-acts">
|
||||
<button
|
||||
class="fm-act"
|
||||
title="편집"
|
||||
@click="open = false; emit('edit', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="fm-act"
|
||||
title="삭제"
|
||||
@click="open = false; emit('delete', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: c.color }"
|
||||
/>
|
||||
<span class="fm-label">{{ c.label }}</span>
|
||||
<span class="fm-ct">{{ counts[c.id] || 0 }}</span>
|
||||
</button>
|
||||
<span class="fm-acts">
|
||||
<button
|
||||
class="fm-act"
|
||||
title="편집"
|
||||
@click="open = false; emit('edit', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="fm-act"
|
||||
title="삭제"
|
||||
@click="open = false; emit('delete', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
class="fm-add"
|
||||
@@ -251,6 +268,19 @@ const allOn = computed(
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* 그룹 구분 헤더(사내 / 내 카테고리) */
|
||||
.fm-group {
|
||||
padding: 0.4375rem 0.5rem 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0187rem;
|
||||
}
|
||||
.fm-group:not(:first-child) {
|
||||
margin-top: 0.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.fm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user