297 lines
7.6 KiB
Vue
297 lines
7.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onMounted, computed } from 'vue'
|
|
import { getCachedImageUrl } from '../../common/imageCache'
|
|
|
|
const props = defineProps<{
|
|
childId?: string | number
|
|
assignFilter?: 'assignable' | 'assigned' | 'none'
|
|
typeFilter?: 'good' | 'bad' | 'all'
|
|
deletable?: boolean
|
|
selectable?: boolean
|
|
}>()
|
|
const emit = defineEmits(['edit-task', 'delete-task'])
|
|
|
|
const tasks = ref<
|
|
{
|
|
id: string
|
|
name: string
|
|
points: number
|
|
is_good: boolean
|
|
image_id?: string | null
|
|
image_url?: string | null
|
|
assigned?: boolean
|
|
}[]
|
|
>([])
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const selectedTasks = ref<string[]>([])
|
|
|
|
const fetchTasks = async () => {
|
|
loading.value = true
|
|
error.value = null
|
|
let url = ''
|
|
if (props.childId) {
|
|
url = `/api/child/${props.childId}/list-all-tasks`
|
|
try {
|
|
const resp = await fetch(url)
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
const data = await resp.json()
|
|
const assigned = (data.assigned_tasks || []).map((task: any) => ({ ...task, assigned: true }))
|
|
const assignable = (data.assignable_tasks || []).map((task: any) => ({
|
|
...task,
|
|
assigned: false,
|
|
}))
|
|
let taskList: any[] = []
|
|
if (props.assignFilter === 'assignable') {
|
|
taskList = assignable
|
|
} else if (props.assignFilter === 'assigned') {
|
|
taskList = assigned
|
|
} else if (props.assignFilter === 'none') {
|
|
taskList = []
|
|
} else {
|
|
taskList = [...assigned, ...assignable]
|
|
}
|
|
// Fetch images for each task if image_id is present
|
|
await Promise.all(
|
|
taskList.map(async (task: any) => {
|
|
if (task.image_id) {
|
|
try {
|
|
task.image_url = await getCachedImageUrl(task.image_id)
|
|
} catch (e) {
|
|
task.image_url = null
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
tasks.value = taskList
|
|
|
|
// If selectable, pre-select assigned tasks
|
|
if (props.selectable) {
|
|
selectedTasks.value = assigned.map((task: any) => String(task.id))
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
|
tasks.value = []
|
|
if (props.selectable) selectedTasks.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
} else {
|
|
url = '/api/task/list'
|
|
try {
|
|
const resp = await fetch(url)
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
const data = await resp.json()
|
|
const taskList = data.tasks || []
|
|
await Promise.all(
|
|
taskList.map(async (task: any) => {
|
|
if (task.image_id) {
|
|
try {
|
|
task.image_url = await getCachedImageUrl(task.image_id)
|
|
} catch (e) {
|
|
task.image_url = null
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
tasks.value = taskList
|
|
if (props.selectable) selectedTasks.value = []
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
|
tasks.value = []
|
|
if (props.selectable) selectedTasks.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(fetchTasks)
|
|
watch(() => [props.childId, props.assignFilter], fetchTasks)
|
|
|
|
const handleEdit = (taskId: string) => {
|
|
emit('edit-task', taskId)
|
|
}
|
|
|
|
const handleDelete = (taskId: string) => {
|
|
emit('delete-task', taskId)
|
|
}
|
|
|
|
defineExpose({ refresh: fetchTasks, selectedTasks })
|
|
|
|
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
|
|
const listHeight = computed(() => {
|
|
// Add a little for padding, separators, etc.
|
|
const n = tasks.value.length
|
|
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
|
|
})
|
|
|
|
const filteredTasks = computed(() => {
|
|
if (props.typeFilter === 'good') {
|
|
return tasks.value.filter((t) => t.is_good)
|
|
} else if (props.typeFilter === 'bad') {
|
|
return tasks.value.filter((t) => !t.is_good)
|
|
}
|
|
return tasks.value
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="task-listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
|
|
<div v-if="loading" class="loading">Loading tasks...</div>
|
|
<div v-else-if="error" class="error">{{ error }}</div>
|
|
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
|
|
<div v-else>
|
|
<div v-for="(task, idx) in filteredTasks" :key="task.id">
|
|
<div
|
|
class="task-list-item"
|
|
:class="{ good: task.is_good, bad: !task.is_good }"
|
|
@click="handleEdit(task.id)"
|
|
>
|
|
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="task-image" />
|
|
<span class="task-name">{{ task.name }}</span>
|
|
<span class="task-points">
|
|
{{ task.is_good ? task.points : '-' + task.points }} pts
|
|
</span>
|
|
<!-- Add checkbox if selectable -->
|
|
<input
|
|
v-if="props.selectable"
|
|
type="checkbox"
|
|
class="task-checkbox"
|
|
v-model="selectedTasks"
|
|
:value="task.id"
|
|
@click.stop
|
|
/>
|
|
<button
|
|
v-if="props.deletable"
|
|
class="delete-btn"
|
|
@click.stop="handleDelete(task.id)"
|
|
aria-label="Delete task"
|
|
type="button"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
|
|
<path
|
|
d="M7 7l6 6M13 7l-6 6"
|
|
stroke="#ef4444"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div v-if="idx < tasks.length - 1" class="task-separator"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.task-listbox {
|
|
flex: 1 1 auto;
|
|
width: auto;
|
|
max-width: 480px;
|
|
/* Subtract any header/nav height if needed, e.g. 4.5rem */
|
|
max-height: calc(100vh - 4.5rem);
|
|
overflow-y: auto;
|
|
margin: 0.2rem 0 0 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.7rem;
|
|
background: #fff5;
|
|
padding: 0.2rem 0.2rem 0.2rem;
|
|
border-radius: 12px;
|
|
}
|
|
.task-list-item {
|
|
display: flex;
|
|
align-items: center;
|
|
border: 2px outset #38c172;
|
|
border-radius: 8px;
|
|
padding: 0.2rem 1rem;
|
|
background: #f8fafc;
|
|
font-size: 1.05rem;
|
|
font-weight: 500;
|
|
transition: border 0.18s;
|
|
margin-bottom: 0.2rem;
|
|
margin-left: 0.2rem;
|
|
margin-right: 0.2rem;
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
|
|
box-sizing: border-box;
|
|
}
|
|
.task-list-item.bad {
|
|
border-color: #e53e3e;
|
|
background: #fff5f5;
|
|
}
|
|
.task-list-item.good {
|
|
border-color: #38c172;
|
|
background: #f0fff4;
|
|
}
|
|
.task-image {
|
|
width: 36px;
|
|
height: 36px;
|
|
object-fit: cover;
|
|
border-radius: 8px;
|
|
margin-right: 0.7rem;
|
|
background: #eee;
|
|
flex-shrink: 0;
|
|
}
|
|
.task-name {
|
|
flex: 1;
|
|
text-align: left;
|
|
}
|
|
.task-points {
|
|
min-width: 60px;
|
|
text-align: right;
|
|
font-weight: 600;
|
|
}
|
|
.loading,
|
|
.error,
|
|
.empty {
|
|
margin: 1.2rem 0;
|
|
color: #888;
|
|
}
|
|
.error {
|
|
color: #e53e3e;
|
|
}
|
|
.task-list-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.task-separator {
|
|
height: 0px;
|
|
background: #0000;
|
|
margin: 0rem 0.2rem;
|
|
border-radius: 0px;
|
|
}
|
|
.delete-btn {
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 50%;
|
|
padding: 0.15rem;
|
|
margin-left: 0.7rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
transition:
|
|
background 0.15s,
|
|
box-shadow 0.15s;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
opacity: 0.92;
|
|
}
|
|
.delete-btn:hover {
|
|
background: #ffeaea;
|
|
box-shadow: 0 0 0 2px #ef444422;
|
|
opacity: 1;
|
|
}
|
|
.delete-btn svg {
|
|
display: block;
|
|
}
|
|
.task-checkbox {
|
|
margin-left: 1rem;
|
|
width: 1.2em;
|
|
height: 1.2em;
|
|
accent-color: #667eea;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|