PHP Classes

File: resources/js/components/TimeLogList.vue

Recommend this page to a friend!
  Packages of Niko Peikrishvili   Ticker   resources/js/components/TimeLogList.vue   Download  
File: resources/js/components/TimeLogList.vue
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Ticker
Application to track and manage to do tasks
Author: By
Last change:
Date: 2 months ago
Size: 38,739 bytes
 

Contents

Class file image Download
<script setup> import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { useTimeLogs } from '@/composables/useTimeLogs'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; const props = defineProps({ onOpenModal: { type: Function, required: true, }, }); const { timeLogs, categories, loading, updateTimeLog, deleteTimeLog, stopTimeLog, fetchCategories } = useTimeLogs(); // Timer state for running logs const now = ref(new Date()); let timerInterval = null; // Check if there are any running logs (no end_time) const hasRunningLogs = computed(() => { return timeLogs.value.some(log => !log.end_time); }); // Start/stop timer based on whether we have running logs watch(hasRunningLogs, (hasRunning) => { if (hasRunning && !timerInterval) { timerInterval = setInterval(() => { now.value = new Date(); }, 1000); } else if (!hasRunning && timerInterval) { clearInterval(timerInterval); timerInterval = null; } }, { immediate: true }); onUnmounted(() => { if (timerInterval) { clearInterval(timerInterval); } }); // Calculate elapsed minutes for a running log const getElapsedMinutes = (log) => { if (!log.start_time || log.end_time) return 0; const [startHour, startMin] = log.start_time.split(':').map(Number); const startTotal = startHour * 60 + startMin; const nowTotal = now.value.getHours() * 60 + now.value.getMinutes(); return Math.max(0, nowTotal - startTotal); }; // Handle stopping a time log const handleStopTimeLog = async (id) => { await stopTimeLog(id); }; const groupBy = ref('none'); // 'none', 'task', 'category' const showActivityModal = ref(false); const editingId = ref(null); const editLog = ref({}); onMounted(() => { fetchCategories(); }); const groupedTimeLogs = computed(() => { if (groupBy.value === 'task') { const groups = {}; for (const log of timeLogs.value) { const key = log.task_id || 'no-task'; if (!groups[key]) { groups[key] = { key, label: log.task_id || '-', total_minutes: 0, logs: [], }; } // Include elapsed time for running logs const minutes = log.end_time ? (log.duration_minutes || 0) : getElapsedMinutes(log); groups[key].total_minutes += minutes; groups[key].logs.push(log); } return Object.values(groups).sort((a, b) => b.total_minutes - a.total_minutes); } if (groupBy.value === 'category') { const groups = {}; for (const log of timeLogs.value) { const key = log.category_id || 'no-category'; if (!groups[key]) { groups[key] = { key, label: log.category?.name || '?? ????', category: log.category, total_minutes: 0, logs: [], }; } // Include elapsed time for running logs const minutes = log.end_time ? (log.duration_minutes || 0) : getElapsedMinutes(log); groups[key].total_minutes += minutes; groups[key].logs.push(log); } return Object.values(groups).sort((a, b) => b.total_minutes - a.total_minutes); } return null; }); const formatMinutes = (minutes) => { if (!minutes || minutes === 0) return '00:00'; const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; }; const totalMinutes = computed(() => { return timeLogs.value.reduce((sum, log) => { if (log.end_time) { // Completed log - use stored duration return sum + (log.duration_minutes || 0); } else { // Running log - calculate elapsed time return sum + getElapsedMinutes(log); } }, 0); }); const totalTime = computed(() => { return formatMinutes(totalMinutes.value); }); // 8 hours = 480 minutes = 100% const TARGET_MINUTES = 480; const progressPercent = computed(() => { return Math.round((totalMinutes.value / TARGET_MINUTES) * 100); }); const progressWidth = computed(() => { return Math.min(progressPercent.value, 100); }); const isOvertime = computed(() => { return progressPercent.value > 100; }); const remainingTime = computed(() => { const remaining = TARGET_MINUTES - totalMinutes.value; if (remaining <= 0) return '00:00'; const hours = Math.floor(remaining / 60); const mins = remaining % 60; return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; }); const progressColor = computed(() => { const percent = progressPercent.value; if (percent <= 50) return 'bg-blue-500'; if (percent <= 80) return 'bg-blue-600'; if (percent <= 100) return 'bg-green-500'; if (percent <= 120) return 'bg-amber-500'; return 'bg-red-500'; }); // Editing functions const startEditing = (log) => { editingId.value = log.id; editLog.value = { start_time: log.start_time, end_time: log.end_time, description: log.description || '', }; }; const saveEdit = async (id) => { await updateTimeLog(id, { start_time: editLog.value.start_time, end_time: editLog.value.end_time, description: editLog.value.description, }); editingId.value = null; }; const cancelEdit = () => { editingId.value = null; editLog.value = {}; }; const handleDelete = (id) => { if (confirm('????????? ????? ??????')) { deleteTimeLog(id); } }; const updateCategory = async (logId, categoryId) => { await updateTimeLog(logId, { category_id: categoryId }); }; // Icon mapping for categories const getCategoryIcon = (iconName) => { const icons = { 'phone': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />`, 'message-square': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />`, 'code': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />`, 'git-pull-request': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 21a3 3 0 100-6 3 3 0 000 6zM6 9a3 3 0 100-6 3 3 0 000 6zM6 9v12M18 15V9a3 3 0 00-3-3H9" />`, 'search': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />`, 'list-todo': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`, 'tag': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />`, 'folder': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />`, 'file': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />`, 'users': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`, 'mail': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />`, 'coffee': `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 8h1a4 4 0 010 8h-1M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8zm4-4h8m-4 0v4" />`, }; return icons[iconName] || icons['tag']; }; // Activity heatmap data - 24 hours x 60 minutes const activityGrid = computed(() => { const grid = Array.from({ length: 24 }, () => Array(60).fill(false)); for (const log of timeLogs.value) { if (!log.start_time || !log.end_time) continue; const [startHour, startMin] = log.start_time.split(':').map(Number); const [endHour, endMin] = log.end_time.split(':').map(Number); const startTotal = startHour * 60 + startMin; const endTotal = endHour * 60 + endMin; for (let m = startTotal; m < endTotal; m++) { const h = Math.floor(m / 60); const min = m % 60; if (h >= 0 && h < 24 && min >= 0 && min < 60) { grid[h][min] = true; } } } return grid; }); const workHoursRange = computed(() => { let firstHour = 24; let lastHour = 0; for (let h = 0; h < 24; h++) { if (activityGrid.value[h].some(m => m)) { firstHour = Math.min(firstHour, h); lastHour = Math.max(lastHour, h); } } if (firstHour > lastHour) { return { start: 8, end: 18 }; } return { start: Math.max(0, firstHour - 1), end: Math.min(23, lastHour + 1), }; }); const getHourWorkedMinutes = (hour) => { return activityGrid.value[hour].filter(m => m).length; }; </script> <template> <div class="h-full flex flex-col" data-tour="time-log-list"> <!-- Controls --> <div class="mb-4 flex items-center justify-between"> <div class="flex items-center gap-2"> <label class="text-sm text-muted-foreground">?????????:</label> <select v-model="groupBy" class="text-sm border rounded px-2 py-1 bg-background text-foreground" > <option value="none">???????</option> <option value="task">????????</option> <option value="category">?????????</option> </select> </div> <div class="text-sm font-medium text-muted-foreground"> ???: {{ totalTime }} </div> </div> <!-- Grouped View --> <div v-if="groupBy !== 'none' && groupedTimeLogs" class="flex-1 overflow-y-auto"> <div class="rounded-md border"> <Table> <TableHeader> <TableRow> <TableHead class="w-[140px]">{{ groupBy === 'task' ? '????????' : '?????????' }}</TableHead> <TableHead class="w-[80px]">???</TableHead> <TableHead class="w-[80px]">????????</TableHead> </TableRow> </TableHeader> <TableBody> <template v-if="loading"> <TableRow> <TableCell colspan="3" class="text-center py-8 text-muted-foreground"> ?????????... </TableCell> </TableRow> </template> <template v-else-if="groupedTimeLogs.length === 0"> <TableRow> <TableCell colspan="3" class="text-center py-8 text-muted-foreground"> ?????????? ?? ???????? </TableCell> </TableRow> </template> <template v-else> <TableRow v-for="group in groupedTimeLogs" :key="group.key"> <TableCell> <div class="flex items-center gap-2"> <template v-if="groupBy === 'category' && group.category"> <svg class="w-4 h-4 flex-shrink-0" :style="{ color: group.category.color }" fill="none" stroke="currentColor" viewBox="0 0 24 24" v-html="getCategoryIcon(group.category.icon)" ></svg> <span class="text-sm">{{ group.category.name }}</span> </template> <template v-else> <span class="font-mono text-xs text-muted-foreground">{{ group.label }}</span> </template> </div> </TableCell> <TableCell> <span class="px-2 py-0.5 bg-[#346ec9]/10 text-[#346ec9] text-xs rounded font-medium"> {{ formatMinutes(group.total_minutes) }} </span> </TableCell> <TableCell class="text-xs text-muted-foreground"> {{ group.logs.length }} </TableCell> </TableRow> </template> </TableBody> </Table> </div> </div> <!-- Regular Time Log Table --> <div v-else class="flex-1 overflow-y-auto"> <div class="rounded-md border"> <Table> <TableHeader> <TableRow> <TableHead class="w-[100px]">????????</TableHead> <TableHead class="w-[50px]">???.</TableHead> <TableHead class="w-[140px]">???</TableHead> <TableHead class="w-[70px]">??????.</TableHead> <TableHead>??????</TableHead> <TableHead class="w-[60px]"></TableHead> </TableRow> </TableHeader> <TableBody> <template v-if="loading"> <TableRow> <TableCell colspan="6" class="text-center py-8 text-muted-foreground"> ?????????... </TableCell> </TableRow> </template> <template v-else-if="timeLogs.length === 0"> <TableRow> <TableCell colspan="6" class="text-center py-8 text-muted-foreground"> ?????????? ?? ???????? </TableCell> </TableRow> </template> <template v-else> <TableRow v-for="log in timeLogs" :key="log.id"> <!-- Task ID --> <TableCell class="font-mono text-xs text-muted-foreground"> {{ log.task_id || '-' }} </TableCell> <!-- Category --> <TableCell> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="sm" class="h-7 w-7 p-0"> <template v-if="log.category"> <svg class="w-4 h-4" :style="{ color: log.category.color }" fill="none" stroke="currentColor" viewBox="0 0 24 24" v-html="getCategoryIcon(log.category.icon)" ></svg> </template> <template v-else> <svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /> </svg> </template> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start"> <DropdownMenuLabel>?????????</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem v-for="cat in categories" :key="cat.id" @click="updateCategory(log.id, cat.id)" > <svg class="w-4 h-4 mr-2" :style="{ color: cat.color }" fill="none" stroke="currentColor" viewBox="0 0 24 24" v-html="getCategoryIcon(cat.icon)" ></svg> {{ cat.name }} </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem @click="updateCategory(log.id, null)"> <svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> ????? </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> <!-- Time Range --> <TableCell> <div v-if="editingId === log.id" class="flex items-center gap-1"> <input v-model="editLog.start_time" type="time" class="w-20 px-1 py-0.5 text-xs border rounded" /> <span class="text-muted-foreground">?</span> <input v-model="editLog.end_time" type="time" class="w-20 px-1 py-0.5 text-xs border rounded" /> </div> <div v-else class="flex items-center gap-1 text-sm font-medium"> <span :class="!log.end_time ? 'text-green-600' : 'text-[#346ec9]'">{{ log.start_time }}</span> <span class="text-muted-foreground">?</span> <template v-if="log.end_time"> <span class="text-[#346ec9]">{{ log.end_time }}</span> </template> <template v-else> <span class="relative flex items-center"> <span class="animate-pulse text-green-600 font-semibold">?????????</span> <span class="absolute -right-1.5 -top-1.5 flex h-2 w-2"> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span> <span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span> </span> </span> </template> </div> </TableCell> <!-- Duration --> <TableCell> <div class="flex items-center gap-2"> <template v-if="!log.end_time"> <!-- Running log - show elapsed time with stop button --> <span class="px-2 py-0.5 bg-green-500/10 text-green-600 text-xs rounded font-medium animate-pulse"> {{ formatMinutes(getElapsedMinutes(log)) }} </span> <Button variant="outline" size="sm" class="h-6 w-6 p-0 text-red-500 border-red-300 hover:text-red-600 hover:bg-red-50 hover:border-red-400" @click="handleStopTimeLog(log.id)" title="????????" > <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <rect x="6" y="6" width="12" height="12" rx="1" /> </svg> </Button> </template> <template v-else> <span class="px-2 py-0.5 bg-[#346ec9]/10 text-[#346ec9] text-xs rounded font-medium"> {{ log.formatted_duration }} </span> </template> </div> </TableCell> <!-- Description --> <TableCell> <div v-if="editingId === log.id" class="flex items-center gap-1"> <Input v-model="editLog.description" class="h-7 text-sm" @keyup.enter="saveEdit(log.id)" @keyup.esc="cancelEdit" /> <Button size="sm" variant="ghost" class="h-7 w-7 p-0" @click="saveEdit(log.id)"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> </svg> </Button> <Button size="sm" variant="ghost" class="h-7 w-7 p-0" @click="cancelEdit"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </Button> </div> <span v-else class="text-sm cursor-pointer" @dblclick="startEditing(log)" > {{ log.description || '' }} </span> </TableCell> <!-- Actions --> <TableCell> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="sm" class="h-8 w-8 p-0"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" /> </svg> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem @click="startEditing(log)"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> </svg> ??????????? </DropdownMenuItem> <DropdownMenuItem @click="handleDelete(log.id)" class="text-red-600"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg> ????? </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> </template> </TableBody> </Table> </div> </div> <!-- Progress Bar --> <div class="mt-4 pt-4 border-t border-border" data-tour="time-log-progress"> <div class="flex items-center justify-between mb-2"> <div class="flex items-center gap-3"> <span class="text-xs text-muted-foreground">??????? ??? (8 ??)</span> <span class="text-xs text-muted-foreground"> ?????: <span :class="isOvertime ? 'text-green-600 font-medium' : 'text-foreground font-medium'">{{ remainingTime }}</span> </span> </div> <div class="flex items-center gap-2"> <span class="text-sm font-semibold" :class="isOvertime ? 'text-amber-600' : hasRunningLogs ? 'text-green-600' : 'text-foreground'" > {{ totalTime }} </span> <span class="text-xs font-medium" :class="isOvertime ? 'text-amber-600' : 'text-muted-foreground'" > ({{ progressPercent }}%) </span> <button @click="showActivityModal = true" class="p-1 rounded hover:bg-muted transition-colors" title="????????? ???????" > <svg class="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> </svg> </button> </div> </div> <div class="h-2 bg-muted rounded-full overflow-hidden"> <div class="h-full rounded-full transition-all duration-300" :class="progressColor" :style="{ width: `${progressWidth}%` }" ></div> </div> <div class="flex justify-between mt-1"> <span class="text-[10px] text-muted-foreground">0:00</span> <span class="text-[10px] text-muted-foreground">8:00</span> </div> </div> <!-- Activity Modal --> <Teleport to="body"> <Transition name="modal"> <div v-if="showActivityModal" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="showActivityModal = false" > <div class="absolute inset-0 bg-[#1f2937]/50"></div> <div class="relative bg-white rounded-xl shadow-xl w-full max-w-3xl overflow-hidden border border-[#e5e7eb]"> <!-- Header --> <div class="bg-[#1f2937] px-6 py-4 flex items-center justify-between"> <h3 class="text-lg font-bold text-white">???? ????????</h3> <button @click="showActivityModal = false" class="text-white/60 hover:text-white transition-colors" > <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> </svg> </button> </div> <!-- Content --> <div class="p-6"> <!-- Legend --> <div class="flex items-center justify-between mb-4"> <div class="text-sm text-muted-foreground"> ???: <span class="font-medium text-foreground">{{ totalTime }}</span> </div> <div class="flex items-center gap-2 text-xs text-muted-foreground"> <span>???????</span> <div class="flex gap-0.5"> <span class="w-3 h-3 rounded-sm bg-[#ebedf0]"></span> <span class="w-3 h-3 rounded-sm bg-[#9be9a8]"></span> <span class="w-3 h-3 rounded-sm bg-[#40c463]"></span> <span class="w-3 h-3 rounded-sm bg-[#30a14e]"></span> <span class="w-3 h-3 rounded-sm bg-[#216e39]"></span> </div> <span>????</span> </div> </div> <!-- Activity Grid --> <div class="overflow-x-auto"> <div class="min-w-[500px]"> <!-- Minutes header (show every 10 minutes) --> <div class="flex mb-1 ml-12"> <div v-for="m in [0, 10, 20, 30, 40, 50]" :key="m" class="text-[10px] text-muted-foreground" :style="{ width: '60px', marginLeft: m === 0 ? '0' : '0' }" > :{{ m.toString().padStart(2, '0') }} </div> </div> <!-- Hour rows --> <div v-for="hour in (workHoursRange.end - workHoursRange.start + 1)" :key="workHoursRange.start + hour - 1" class="flex items-center gap-1 mb-0.5" > <!-- Hour label --> <div class="w-10 text-right text-[10px] text-muted-foreground pr-1"> {{ (workHoursRange.start + hour - 1).toString().padStart(2, '0') }}:00 </div> <!-- 60 minute boxes --> <div class="flex gap-px"> <div v-for="minute in 60" :key="minute" class="w-1.5 h-3 rounded-sm transition-colors" :class="activityGrid[workHoursRange.start + hour - 1][minute - 1] ? 'bg-[#40c463]' : 'bg-[#ebedf0]'" :title="`${(workHoursRange.start + hour - 1).toString().padStart(2, '0')}:${(minute - 1).toString().padStart(2, '0')}`" ></div> </div> <!-- Hour total --> <div class="w-12 text-[10px] text-muted-foreground pl-2"> {{ getHourWorkedMinutes(workHoursRange.start + hour - 1) }} ?? </div> </div> </div> </div> <!-- Summary --> <div class="mt-4 pt-4 border-t border-border flex items-center justify-between text-sm text-muted-foreground"> <span>{{ timeLogs.length }} ????????</span> <span>{{ workHoursRange.start }}:00 - {{ workHoursRange.end + 1 }}:00</span> </div> </div> </div> </div> </Transition> </Teleport> </div> </template> <style scoped> .modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; } .modal-enter-from, .modal-leave-to { opacity: 0; } .modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.2s ease; } .modal-enter-from .relative, .modal-leave-to .relative { transform: scale(0.95); } </style>