141 lines
4.9 KiB
Vue
141 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
interface ServiceItem {
|
|
id: string
|
|
code: string
|
|
name: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
services: ServiceItem[]
|
|
serviceRows?: string[][]
|
|
modelValue: string[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: string[]): void
|
|
}>()
|
|
const { t } = useI18n()
|
|
|
|
const selectedSet = computed(() => new Set(props.modelValue))
|
|
const serviceById = computed(() => new Map(props.services.map(item => [item.id, item])))
|
|
const firstRowIds = computed(() => {
|
|
const rows = Array.isArray(props.serviceRows) ? props.serviceRows : []
|
|
if (rows.length === 0) return [] as string[]
|
|
const firstRow = Array.isArray(rows[0]) ? rows[0] : []
|
|
return firstRow.filter(id => serviceById.value.has(id))
|
|
})
|
|
const firstRowIdSet = computed(() => new Set(firstRowIds.value))
|
|
const groupedRows = computed(() => {
|
|
const rows = Array.isArray(props.serviceRows) ? props.serviceRows : []
|
|
if (rows.length === 0) return [] as ServiceItem[][]
|
|
|
|
const used = new Set<string>()
|
|
const grouped = rows
|
|
.map(row => row.map(id => serviceById.value.get(id)).filter((item): item is ServiceItem => Boolean(item)))
|
|
.map(row => row.filter(item => {
|
|
if (used.has(item.id)) return false
|
|
used.add(item.id)
|
|
return true
|
|
}))
|
|
.filter(row => row.length > 0)
|
|
|
|
const leftovers = props.services.filter(item => !used.has(item.id))
|
|
if (leftovers.length > 0) grouped.push(leftovers)
|
|
return grouped
|
|
})
|
|
|
|
const toggleService = (id: string, checked: boolean) => {
|
|
const next = new Set(props.modelValue)
|
|
if (checked) {
|
|
if (firstRowIdSet.value.has(id)) {
|
|
firstRowIds.value.forEach(firstId => next.delete(firstId))
|
|
}
|
|
next.add(id)
|
|
} else {
|
|
next.delete(id)
|
|
}
|
|
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
|
|
}
|
|
|
|
const isFirstRowDisabled = (id: string) => {
|
|
if (!firstRowIdSet.value.has(id)) return false
|
|
for (const selectedId of props.modelValue) {
|
|
if (selectedId !== id && firstRowIdSet.value.has(selectedId)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
watch(
|
|
() => [props.modelValue, firstRowIds.value] as const,
|
|
() => {
|
|
const firstSelected = props.modelValue.filter(id => firstRowIdSet.value.has(id))
|
|
if (firstSelected.length <= 1) return
|
|
const keepId = firstRowIds.value.find(id => firstSelected.includes(id)) || firstSelected[0]
|
|
const next = props.modelValue.filter(id => !firstRowIdSet.value.has(id) || id === keepId)
|
|
emit('update:modelValue', next)
|
|
},
|
|
{ immediate: true, deep: true }
|
|
)
|
|
|
|
const clearAll = () => {
|
|
emit('update:modelValue', [])
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
|
|
<div class="mb-1 flex items-center justify-between gap-2">
|
|
<div class="flex min-w-0 items-center gap-1.5">
|
|
<label class="block shrink-0 text-sm font-semibold leading-none text-slate-900">{{ t('serviceSelector.title') }}</label>
|
|
<span class="min-w-0 text-xs leading-5 text-muted-foreground">{{ t('serviceSelector.titleHint') }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
|
@click="clearAll"
|
|
>
|
|
{{ t('serviceSelector.clear') }}
|
|
</button>
|
|
</div>
|
|
<div class="rounded-md border p-1.5">
|
|
<div v-if="groupedRows.length > 0" class="flex flex-col gap-1.5">
|
|
<div
|
|
v-for="(row, rowIndex) in groupedRows"
|
|
:key="`service-row-${rowIndex}`"
|
|
class="flex flex-wrap items-start gap-1 border-b border-slate-200 pb-1.5 last:border-b-0 last:pb-0"
|
|
>
|
|
<label
|
|
v-for="item in row"
|
|
:key="item.id"
|
|
:class="[
|
|
'inline-flex w-fit max-w-full items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 transition',
|
|
isFirstRowDisabled(item.id)
|
|
? 'cursor-not-allowed border-slate-300 bg-slate-100/80 text-slate-400 opacity-80'
|
|
: 'cursor-pointer hover:bg-muted/60'
|
|
]"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
:class="[
|
|
'mt-0.5',
|
|
isFirstRowDisabled(item.id) ? 'cursor-not-allowed accent-slate-300' : 'cursor-pointer'
|
|
]"
|
|
:checked="selectedSet.has(item.id)"
|
|
:disabled="isFirstRowDisabled(item.id)"
|
|
@change="toggleService(item.id, ($event.target as HTMLInputElement).checked)"
|
|
/>
|
|
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
|
|
<span class="text-foreground break-words">{{ item.name }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
|
|
{{ t('serviceSelector.empty') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|