feat(i18n): 添加多语言支持并实现国际化功能

This commit is contained in:
sudomarcma
2025-07-02 13:43:16 +08:00
parent 2560996333
commit 3aa4897bc5
17 changed files with 996 additions and 913 deletions
+3 -3
View File
@@ -2,14 +2,14 @@ import mysql from 'mysql2/promise'
import bcrypt from 'bcrypt'
import dotenv from 'dotenv'
dotenv.config({ path: '../.env' })
dotenv.config()
async function hashPasswords() {
const db = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
database: process.env.DB_DATABASE,
port: process.env.DB_PORT,
})
+65
View File
@@ -25,6 +25,7 @@
"qrcode": "^1.5.4",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.7",
"vue-router": "^4.5.0"
},
"devDependencies": {
@@ -1269,6 +1270,50 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@intlify/core-base": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.7.tgz",
"integrity": "sha512-gYiGnQeJVp3kNBeXQ73m1uFOak0ry4av8pn+IkEWigyyPWEMGzB+xFeQdmGMFn49V+oox6294oGVff8bYOhtOw==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.7",
"@intlify/shared": "11.1.7"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.7.tgz",
"integrity": "sha512-0ezkep1AT30NyuKj8QbRlmvMORCCRlOIIu9v8RNU8SwDjjTiFCZzczCORMns2mCH4HZ1nXgrfkKzYUbfjNRmng==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.7",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.7.tgz",
"integrity": "sha512-4yZeMt2Aa/7n5Ehy4KalUlvt3iRLcg1tq9IBVfOgkyWFArN4oygn6WxgGIFibP3svpaH8DarbNaottq+p0gUZQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@ionic/cli-framework-output": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
@@ -9479,6 +9524,26 @@
"eslint": "^8.57.0 || ^9.0.0"
}
},
"node_modules/vue-i18n": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.7.tgz",
"integrity": "sha512-CDrU7Cmyh1AxJjerQmipV9nVa//exVBdhTcWGlbfcDCN8bKp/uAe7Le6IoN4//5emIikbsSKe9Uofmf/xXkhOA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.7",
"@intlify/shared": "11.1.7",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"qrcode": "^1.5.4",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-i18n": "^11.1.7",
"vue-router": "^4.5.0"
},
"devDependencies": {
+35 -15
View File
@@ -1,24 +1,26 @@
<template>
<div
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300"
>
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300">
<header
class="flex justify-between items-center px-4 py-3 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300"
>
<h1 class="text-xl sm:text-2xl font-bold">Clock-In/Out System</h1>
class="flex justify-between items-center px-4 py-3 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300">
<h1 class="text-xl sm:text-2xl font-bold">{{ $t('appTitle') }}</h1>
<div class="flex items-center gap-4">
<button
v-if="isLoggedIn"
@click="logout"
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-md font-semibold transition-colors duration-200"
>
Logout
<!-- Language Selector -->
<select v-model="currentLang" @change="changeLang"
class="px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
style="font-size: 0.9em;">
<option value="en">{{ $t('english') }}</option>
<option value="ms">{{ $t('malay') }}</option>
</select>
<button v-if="isLoggedIn" @click="logout"
class="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-md font-semibold transition-colors duration-200">
{{ $t('logout') }}
</button>
<button
@click="toggleTheme"
<button @click="toggleTheme"
class="flex items-center justify-center w-11 h-11 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-lg rounded-full transition-colors duration-200"
title="Toggle Theme"
>
title="Toggle Theme">
<span v-if="isDarkMode"></span>
<span v-else>🌙</span>
</button>
@@ -33,6 +35,9 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
const isDarkMode = ref(false)
const router = useRouter()
@@ -40,6 +45,14 @@ const route = useRoute()
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
// Language switch logic
const currentLang = ref(locale.value)
const changeLang = () => {
locale.value = currentLang.value
localStorage.setItem('lang', currentLang.value)
}
watch(
() => route.path,
() => {
@@ -69,9 +82,16 @@ const updateTheme = () => {
}
onMounted(() => {
// Restore theme
const savedTheme = localStorage.getItem('darkMode')
isDarkMode.value = savedTheme === 'true'
updateTheme()
// Restore language
const savedLang = localStorage.getItem('lang')
if (savedLang) {
currentLang.value = savedLang
locale.value = savedLang
}
})
</script>
+104 -210
View File
@@ -2,68 +2,47 @@
<div class="flex flex-col lg:flex-row gap-8 attendance-reporting-layout">
<div class="selection-panel w-full lg:w-1/2">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 h-full flex flex-col">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">1. Select Workers</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('selectWorkers') }}</h2>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 mb-4">
<div class="search-box relative flex-grow">
<input
type="text"
v-model="searchQuery"
placeholder="Search for a worker..."
<input type="text" v-model="searchQuery" :placeholder="$t('searchWorkerPlaceholder')"
class="form-input w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
@keydown.enter.prevent="handleSearch"
@keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)"
@keydown.esc.prevent="clearSearch"
/>
<div
v-if="searchResults.length > 0"
class="search-results-list absolute top-full left-0 right-0 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 border-t-0 rounded-b-md shadow-lg z-10 max-h-60 overflow-y-auto"
>
@keydown.enter.prevent="handleSearch" @keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)" @keydown.esc.prevent="clearSearch" />
<div v-if="searchResults.length > 0"
class="search-results-list absolute top-full left-0 right-0 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 border-t-0 rounded-b-md shadow-lg z-10 max-h-60 overflow-y-auto">
<ul>
<li
v-for="(worker, index) in searchResults"
:key="worker.id"
<li v-for="(worker, index) in searchResults" :key="worker.id"
:class="{ 'bg-blue-100 dark:bg-blue-900': index === highlightedIndex }"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-100 dark:border-gray-700 last:border-b-0 text-gray-900 dark:text-white"
@click="selectWorker(worker)"
@mouseenter="highlightedIndex = index"
>
@click="selectWorker(worker)" @mouseenter="highlightedIndex = index">
{{ worker.full_name }}
</li>
</ul>
</div>
</div>
<button
@click="handleSelectAll"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto"
>
Select All
<button @click="handleSelectAll"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
{{ $t('selectAll') }}
</button>
</div>
<div class="tag-selection mb-6">
<div class="form-group flex flex-col gap-2">
<label for="tag-select" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Add all workers from a tag</label
>
<label for="tag-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
$t('addWorkersByTag') }}</label>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 items-end">
<select
id="tag-select"
v-model="selectedTagId"
class="form-input w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option :value="null" disabled>-- Choose a tag --</option>
<select id="tag-select" v-model="selectedTagId"
class="form-input w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option :value="null" disabled>{{ $t('chooseTag') }}</option>
<option v-for="tag in allTags" :key="tag.id" :value="tag.id">
{{ tag.tag_name }}
</option>
</select>
<button
@click="addWorkersByTag"
:disabled="!selectedTagId"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto"
>
Add by Tag
<button @click="addWorkersByTag" :disabled="!selectedTagId"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
{{ $t('addByTag') }}
</button>
</div>
</div>
@@ -71,41 +50,33 @@
<div class="selected-workers-list flex-grow">
<h4 class="text-lg font-semibold mb-3 text-gray-800 dark:text-white">
Selected for Report ({{ selectedWorkers.length }})
{{ $t('selectedForReport', { count: selectedWorkers.length }) }}
</h4>
<ul v-if="isSelectAllActive" class="space-y-2">
<li
class="flex justify-between items-center bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-4 py-2 text-gray-700 dark:text-gray-200"
>
<span>All Workers ({{ selectedWorkers.length }}) Selected</span>
<button
@click="clearAllSelection"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-xl leading-none"
>
class="flex justify-between items-center bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-4 py-2 text-gray-700 dark:text-gray-200">
<span>{{ $t('allWorkersSelected', { count: selectedWorkers.length }) }}</span>
<button @click="clearAllSelection"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-xl leading-none">
&times;
</button>
</li>
</ul>
<ul v-else-if="selectedWorkers.length > 0" class="flex flex-wrap gap-2">
<li
v-for="worker in selectedWorkers"
:key="worker.id"
class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-1 text-gray-700 dark:text-gray-200 text-sm font-medium"
>
<li v-for="worker in selectedWorkers" :key="worker.id"
class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-1 text-gray-700 dark:text-gray-200 text-sm font-medium">
<span>{{ worker.full_name }}</span>
<button
@click="removeWorker(worker.id)"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-base leading-none"
>
<button @click="removeWorker(worker.id)"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-base leading-none">
&times;
</button>
</li>
</ul>
<p v-else class="empty-state text-gray-500 dark:text-gray-400 italic mt-4">
No workers selected.
{{ $t('noWorkersSelected') }}
</p>
</div>
</section>
@@ -113,123 +84,84 @@
<div class="results-panel w-full lg:w-1/2">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">2. Report Settings</h2>
<div
class="filters flex flex-col sm:flex-row gap-4 border-b border-gray-200 dark:border-gray-700 pb-6 mb-6"
>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('reportSettings') }}</h2>
<div class="filters flex flex-col sm:flex-row gap-4 border-b border-gray-200 dark:border-gray-700 pb-6 mb-6">
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Start Date</label
>
<input
type="date"
id="start-date"
v-model="filters.startDate"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate')
}}</label>
<input type="date" id="start-date" v-model="filters.startDate"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>End Date</label
>
<input
type="date"
id="end-date"
v-model="filters.endDate"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
}}</label>
<input type="date" id="end-date" v-model="filters.endDate"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</div>
<div
class="overtime-settings-section flex flex-col gap-5"
v-if="selectedWorkers.length > 0"
>
<div class="overtime-settings-section flex flex-col gap-5" v-if="selectedWorkers.length > 0">
<div class="worker-salaries border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">
Monthly Salary (RM)
{{ $t('monthlySalary') }}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
Applied to all selected workers.
{{ $t('salaryAppliedNote') }}
</p>
<div class="form-group flex flex-col gap-2">
<input
id="monthly-salary"
type="number"
v-model.number="monthlySalary"
<input id="monthly-salary" type="number" v-model.number="monthlySalary"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., 3000"
/>
:placeholder="$t('salaryPlaceholder')" />
</div>
</div>
<div class="factor-settings border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">OT Factors</h4>
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">{{ $t('otFactors') }}</h4>
<div class="factor-input flex flex-col sm:flex-row gap-4">
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Weekend Factor</p>
<input
id="rest-day-factor"
type="number"
v-model.number="overtimeSettings.restDayFactor"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('weekendFactor') }}</p>
<input id="rest-day-factor" type="number" v-model.number="overtimeSettings.restDayFactor"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Holiday Factor</p>
<input
id="holiday-factor"
type="number"
v-model.number="overtimeSettings.publicHolidayFactor"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('holidayFactor') }}</p>
<input id="holiday-factor" type="number" v-model.number="overtimeSettings.publicHolidayFactor"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
</div>
</div>
<div class="holiday-picker">
<h5 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
Select Public Holidays
{{ $t('selectPublicHolidays') }}
</h5>
<div class="calendar border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-w-sm mx-auto">
<div
class="calendar border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-w-sm mx-auto"
>
<div
class="calendar-header flex justify-between items-center font-semibold mb-4 text-gray-800 dark:text-white"
>
<button
@click="changeMonth(-1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150"
>
class="calendar-header flex justify-between items-center font-semibold mb-4 text-gray-800 dark:text-white">
<button @click="changeMonth(-1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
&lt;
</button>
<span>{{ calendarGrid.monthName }} {{ calendarGrid.year }}</span>
<button
@click="changeMonth(1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150"
>
<button @click="changeMonth(1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
&gt;
</button>
</div>
<div
class="calendar-weekdays grid grid-cols-7 text-center text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2"
>
class="calendar-weekdays grid grid-cols-7 text-center text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2">
<div v-for="day in calendarGrid.weekdayLabels" :key="day" class="weekday">
{{ day }}
</div>
</div>
<div class="calendar-days grid grid-cols-7 text-center">
<div
v-for="(day, index) in calendarGrid.grid"
:key="index"
<div v-for="(day, index) in calendarGrid.grid" :key="index"
class="day-cell h-9 w-9 flex items-center justify-center rounded-full cursor-pointer transition-colors duration-100"
:class="{
'text-gray-400 dark:text-gray-500 cursor-default': day.type === 'padding',
'bg-blue-600 text-white font-bold': day.isHoliday,
'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-white':
day.type === 'day' && !day.isHoliday,
}"
@click="day.type === 'day' && toggleHoliday(day.dateString)"
>
}" @click="day.type === 'day' && toggleHoliday(day.dateString)">
<span v-if="day.type === 'day'">{{ day.date }}</span>
</div>
</div>
@@ -238,59 +170,42 @@
</div>
<div class="action-buttons mt-8 flex justify-end">
<button
@click="generateReport"
:disabled="!canGenerate"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-3 rounded-md w-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
Generate Attendance & OT Report
<button @click="generateReport" :disabled="!canGenerate"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-3 rounded-md w-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
{{ $t('generateReport') }}
</button>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8" v-if="reportGenerated">
<div v-if="overtimeReport" class="overtime-report mb-8">
<div
class="report-header flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4"
>
<div class="report-header flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-2 sm:mb-0">
Overtime Pay Summary
{{ $t('overtimePaySummary') }}
</h4>
<button
@click="exportOtSummaryCsv"
:disabled="!overtimeReport"
class="button-primary bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Export OT Summary (CSV)
<button @click="exportOtSummaryCsv" :disabled="!overtimeReport"
class="button-primary bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('exportOtSummary') }}
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-[400px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Worker
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('worker') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Total Hours Worked
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('totalHoursWorked') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Total OT Pay (RM)
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('totalOtPay') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="(report, name) in overtimeReport"
:key="name"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<tr v-for="(report, name) in overtimeReport" :key="name"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ report.totalHours.toFixed(2) }}
@@ -306,16 +221,11 @@
<div class="raw-logs" v-if="reportData.length > 0">
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
Raw Attendance Data
{{ $t('rawAttendanceData') }}
</h4>
<div
v-for="(group, workerName) in groupedReportData"
:key="workerName"
class="worker-group mb-6 last:mb-0"
>
<div v-for="(group, workerName) in groupedReportData" :key="workerName" class="worker-group mb-6 last:mb-0">
<h5
class="worker-group-header bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md mb-3"
>
class="worker-group-header bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md mb-3">
{{ workerName }}
</h5>
<div class="overflow-x-auto">
@@ -323,42 +233,32 @@
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Event
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('event') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Timestamp
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('timsstamp') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Location
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('location') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Notes
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('notes') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="record in group"
:key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<tr v-for="record in group" :key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3">
<span
class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase text-white"
:class="{
<span class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase text-white" :class="{
'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out',
'bg-yellow-500': record.event_type === 'failed',
}"
>
}">
{{ record.event_type.replace('_', ' ') }}
</span>
</td>
@@ -368,7 +268,7 @@
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.qrCodeUsedName }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || 'N/A' }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
</tr>
</tbody>
</table>
@@ -376,11 +276,8 @@
</div>
</div>
</section>
<section
class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8 text-center"
v-if="loadingReport"
>
<p class="text-lg font-medium text-gray-700 dark:text-gray-200">Loading Report...</p>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8 text-center" v-if="loadingReport">
<p class="text-lg font-medium text-gray-700 dark:text-gray-200">{{ $t('loadingReport') }}</p>
</section>
</div>
</div>
@@ -454,10 +351,7 @@ const calendarGrid = computed(() => {
}
for (let day = 1; day <= daysInMonth; day++) {
const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(
2,
'0',
)}`
const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
grid.push({
type: 'day',
date: day,
@@ -510,7 +404,7 @@ const addWorkersByTag = async () => {
selectedTagId.value = null
} catch (err) {
console.error('Failed to fetch workers by tag', err)
alert('Could not load workers for the selected tag.')
alert($t('tagLoadError'));
}
}
@@ -583,7 +477,7 @@ const removeWorker = (workerId) => {
const generateReport = async () => {
if (!canGenerate.value) {
alert('Please select workers, set valid date range, and enter a salary.')
alert($t('generateReportError'));
return
}
loadingReport.value = true
@@ -637,7 +531,7 @@ const generateReport = async () => {
reportGenerated.value = true
} catch (err) {
console.error('Failed to generate report', err)
alert('An error occurred while generating the report.')
alert($t('reportGenerationError'));
} finally {
loadingReport.value = false
}
@@ -669,10 +563,10 @@ const exportOtSummaryCsv = () => {
const eventType = record.event_type === 'clock_in' ? 'Clock In' : 'Clock Out'
const timestamp = new Date(record.timestamp).toLocaleString()
let row = [
`"${worker.full_name}"`,
`"${eventType}"`,
`"${timestamp}"`,
`"${record.qrCodeUsedName}"`,
`${worker.full_name}`,
`${eventType}`,
`${timestamp}`,
`${record.qrCodeUsedName}`,
]
if (record.event_type === 'clock_in') {
@@ -708,7 +602,7 @@ const exportOtSummaryCsv = () => {
}
}
const summaryRow = [`"${worker.full_name} Total"`, '', '', '', '', workerTotalWage.toFixed(2)]
const summaryRow = [`${worker.full_name} Total`, '', '', '', '', workerTotalWage.toFixed(2)]
allRows.push(summaryRow.join(','))
allRows.push('')
+142 -292
View File
@@ -1,59 +1,39 @@
<template>
<div class="flex flex-col gap-8 pb-20">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">Add New User</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
<div class="flex flex-col gap-2">
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Full Name</label
>
<input
type="text"
id="fullName"
v-model="newWorker.fullName"
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName')
}}</label>
<input type="text" id="fullName" v-model="newWorker.fullName"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., John Smith"
/>
:placeholder="$t('egJohnSmith')" />
</div>
<div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Username</label
>
<input
type="text"
id="username"
v-model="newWorker.username"
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('username')
}}</label>
<input type="text" id="username" v-model="newWorker.username"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., jsmith"
/>
:placeholder="$t('egJsmith')" />
</div>
<div class="flex flex-col gap-2">
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Password</label
>
<input
type="password"
id="password"
v-model="newWorker.password"
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('password')
}}</label>
<input type="password" id="password" v-model="newWorker.password"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., 123456"
/>
:placeholder="$t('eg123456')" />
</div>
<div class="flex flex-col justify-end">
<label class="flex items-center text-sm mb-2 cursor-pointer">
<input
type="checkbox"
v-model="isManager"
class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500"
/>
<span class="text-gray-700 dark:text-gray-300">As Manager</span>
<input type="checkbox" v-model="isManager"
class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
<span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span>
</label>
<button
@click="addWorker"
:disabled="!isFormValid || loading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ loading ? 'Adding...' : 'Add User' }}
<button @click="addWorker" :disabled="!isFormValid || loading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ loading ? $t('adding') : $t('addUser') }}
</button>
</div>
</div>
@@ -61,48 +41,29 @@
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">Manage Tags</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('manageTags') }}</h2>
<div class="flex flex-col sm:flex-row items-end gap-4 mb-4">
<div class="flex flex-col gap-2 flex-grow w-full sm:w-auto">
<label for="new-tag" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Create New Tag</label
>
<input
type="text"
id="new-tag"
v-model="newTagName"
@keyup.enter="createTag"
<label for="new-tag" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('createNewTag')
}}</label>
<input type="text" id="new-tag" v-model="newTagName" @keyup.enter="createTag"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., Team A, Senior, Project X"
/>
:placeholder="$t('egTeam')" />
</div>
<button
@click="createTag"
:disabled="!newTagName"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
>
Create Tag
<button @click="createTag" :disabled="!newTagName"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
{{ $t('createTag') }}
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[2rem]">
<span
v-for="tag in allTags"
:key="tag.id"
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1"
>
<span v-for="tag in allTags" :key="tag.id"
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
{{ tag.tag_name }}
<button @click="deleteTag(tag.id)" class="text-red-500 hover:text-red-700 ml-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
clip-rule="evenodd" />
</svg>
</button>
</span>
@@ -110,37 +71,22 @@
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">Worker Roster</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:gap-0 sm:items-center justify-between">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name or username..."
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrUsername')"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mr-1"
>Filter by Tag:</span
>
<button
v-for="tag in allTags"
:key="tag.id"
@click="toggleTagFilter(tag.id)"
:class="{
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mr-1">{{ $t('filterByTag') }}</span>
<button v-for="tag in allTags" :key="tag.id" @click="toggleTagFilter(tag.id)" :class="{
'bg-blue-600 text-white': selectedTagIds.includes(tag.id),
'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600':
!selectedTagIds.includes(tag.id),
}"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors duration-200"
>
}" class="px-3 py-1 rounded-full text-sm font-medium transition-colors duration-200">
{{ tag.tag_name }}
</button>
<button
v-if="selectedTagIds.length > 0"
@click="clearTagFilter"
class="text-blue-600 hover:text-blue-800 text-sm font-medium ml-2"
>
Clear Filter
<button v-if="selectedTagIds.length > 0" @click="clearTagFilter"
class="text-blue-600 hover:text-blue-800 text-sm font-medium ml-2">
{{ $t('clearFilter') }}
</button>
</div>
</div>
@@ -149,229 +95,146 @@
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th class="w-12 px-2 py-3 text-center">
<input
type="checkbox"
@change="toggleSelectAll"
:checked="isAllSelected"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
/>
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('fullName') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('username') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('tags') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('dateJoined') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Full Name
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Username
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Tags
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Date Joined
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right"
>
Actions
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
{{ $t('actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="worker in workers"
:key="worker.id"
<tr v-for="worker in workers" :key="worker.id"
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-2 py-3 text-center">
<input
type="checkbox"
:checked="isWorkerSelected(worker.id)"
@change="toggleWorkerSelection(worker.id)"
class="form-checkbox h-4 w-4 text-blue-600 rounded"
/>
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)"
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
<td class="px-4 py-3">
<template v-if="worker.tags">
<span
v-for="tag in worker.tags.split(', ')"
:key="tag"
class="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-2 py-1 rounded-full text-xs font-medium mr-1 mb-1 inline-block"
>
<span v-for="tag in worker.tags.split(', ')" :key="tag"
class="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-2 py-1 rounded-full text-xs font-medium mr-1 mb-1 inline-block">
{{ tag }}
</span>
</template>
<span v-else class="text-gray-500 dark:text-gray-400">N/A</span>
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(worker.created_at).toLocaleDateString() }}
</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button
@click="openTagEditor(worker)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
Edit Tags
<button @click="openTagEditor(worker)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('editTags') }}
</button>
<button
@click="openPasswordModal(worker)"
class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
Password
<button @click="openPasswordModal(worker)"
class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('password') }}
</button>
<button
@click="viewRecords(worker.id)"
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
View Records
<button @click="viewRecords(worker.id)"
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('viewRecords') }}
</button>
<button
@click="deleteWorker(worker.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"
>
Delete
<button @click="deleteWorker(worker.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('delete') }}
</button>
</td>
</tr>
<tr v-if="workers.length === 0">
<td colspan="6" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? 'Loading workers...' : 'No workers found.' }}
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="totalPages > 1"
class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"
>
<button
@click="changePage(currentPage - 1)"
:disabled="currentPage <= 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white"
>
Previous
<div v-if="totalPages > 1"
class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
{{ $t('previous') }}
</button>
<span class="text-gray-700 dark:text-gray-200"
>Page <span class="font-semibold">{{ currentPage }}</span> of
<span class="font-semibold">{{ totalPages }}</span></span
>
<button
@click="changePage(currentPage + 1)"
:disabled="currentPage >= totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white"
>
Next
<span class="text-gray-700 dark:text-gray-200">
{{ $t('pageOf', { current: currentPage, total: totalPages }) }}
</span>
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
{{ $t('next') }}
</button>
</div>
</section>
<div v-if="isEditingTags" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div
v-if="isEditingTags"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md transform transition-all scale-100 opacity-100"
>
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md transform transition-all scale-100 opacity-100">
<h3 class="text-2xl font-bold mb-6 text-gray-800 dark:text-white">{{ editorTitle }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<label
v-for="tag in allTags"
:key="tag.id"
class="flex items-center gap-3 cursor-pointer text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 p-3 rounded-lg transition-all duration-200 ease-in-out border border-transparent hover:border-blue-300 dark:hover:border-blue-700"
>
<label v-for="tag in allTags" :key="tag.id"
class="flex items-center gap-3 cursor-pointer text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 p-3 rounded-lg transition-all duration-200 ease-in-out border border-transparent hover:border-blue-300 dark:hover:border-blue-700">
<div class="relative flex items-center">
<input
type="checkbox"
:checked="isTagAppliedToSelection(tag.id)"
@change="toggleTagForSelection(tag.id)"
class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-400 text-blue-600 transition-all checked:border-blue-600 checked:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-500 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:focus:ring-blue-400"
/>
<input type="checkbox" :checked="isTagAppliedToSelection(tag.id)" @change="toggleTagForSelection(tag.id)"
class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-400 text-blue-600 transition-all checked:border-blue-600 checked:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-500 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:focus:ring-blue-400" />
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 transition-opacity peer-checked:opacity-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
stroke="currentColor"
stroke-width="1"
>
<path
fill-rule="evenodd"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 transition-opacity peer-checked:opacity-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"
stroke="currentColor" stroke-width="1">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
clip-rule="evenodd" />
</svg>
</span>
</div>
<span class="text-base font-medium select-none">{{ tag.tag_name }}</span>
</label>
<p
v-if="allTags.length === 0"
class="col-span-2 text-center text-gray-500 dark:text-gray-400"
>
No tags available. Create some in "Manage Tags" section.
<p v-if="allTags.length === 0">
{{ $t('noTagsAvailable') }}
</p>
</div>
<button
@click="closeTagEditor"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2 rounded-md mt-8 w-full transition-colors duration-200"
>
Done
<button @click="closeTagEditor"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2 rounded-md mt-8 w-full transition-colors duration-200">
{{ $t('done') }}
</button>
</div>
</div>
<div
v-if="isPasswordModalVisible"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4"
>
<div v-if="isPasswordModalVisible"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">Change Password</h3>
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">{{ $t('changePassword') }}</h3>
<p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300">
For user: <span class="font-semibold">{{ editingWorkerPassword.full_name }}</span>
For user: <span class="font-semibold">{{ $t('forUser') }}: ...</span>
</p>
<form @submit.prevent="updateWorkerPassword">
<div class="flex flex-col gap-4">
<div>
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>New Password</label
>
<input
type="password"
id="newPassword"
v-model="newPassword"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
$t('newPassword') }}</label>
<input type="password" id="newPassword" v-model="newPassword" required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label
for="confirmNewPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Confirm New Password</label
>
<input
type="password"
id="confirmNewPassword"
v-model="confirmNewPassword"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
$t('confirmNewPassword') }}</label>
<input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">
{{ passwordErrorMessage }}
@@ -381,43 +244,30 @@
</p>
</div>
<div class="flex justify-end gap-4 mt-8">
<button
type="button"
@click="closePasswordModal"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors"
>
Cancel
<button type="button" @click="closePasswordModal"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors">
{{ $t('cancel') }}
</button>
<button
type="submit"
:disabled="passwordLoading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50"
>
{{ passwordLoading ? 'Saving...' : 'Save Password' }}
<button type="submit" :disabled="passwordLoading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
{{ passwordLoading ? $t('saving') : $t('savePassword') }}
</button>
</div>
</form>
</div>
</div>
<div
v-if="selectedWorkerIds.length > 0"
class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg py-4 px-8 flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-6 z-40"
>
<span class="font-medium text-gray-800 dark:text-white"
>{{ selectedWorkerIds.length }} worker(s) selected</span
>
<button
@click="openBulkTagEditor"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto"
>
Bulk Edit Tags
<div v-if="selectedWorkerIds.length > 0"
class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg py-4 px-8 flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-6 z-40">
<span class="font-medium text-gray-800 dark:text-white">{{ selectedWorkerIds.length }} worker(s) selected</span>
<button @click="openBulkTagEditor"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
{{ $t('bulkEditTags') }}
</button>
<button
@click="selectedWorkerIds = []"
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto"
>
Clear Selection
<button @click="selectedWorkerIds = []"
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
{{ $t('clearSelection') }}
</button>
</div>
</div>
@@ -512,7 +362,7 @@ const fetchInitialData = async () => {
totalWorkers.value = workersData.totalCount
allTags.value = tagsData
} catch (err) {
errorMessage.value = 'Failed to load page data.'
errorMessage.value = $t('failedToLoadPageData')
console.error(err)
} finally {
loading.value = false
@@ -531,7 +381,7 @@ const fetchWorkers = async (page = currentPage.value) => {
totalWorkers.value = data.totalCount
currentPage.value = page
} catch (err) {
errorMessage.value = 'Failed to fetch workers.'
errorMessage.value = $t('failedToFetchWorkers')
console.error(err)
} finally {
loading.value = false
@@ -561,7 +411,7 @@ const addWorker = async () => {
newWorker.value = { fullName: '', username: '', password: '' }
isManager.value = false
} catch (err) {
errorMessage.value = err.message || 'An error occurred while adding the user.'
errorMessage.value = err.message || $t('errorAddingUser')
console.error(err)
} finally {
loading.value = false
@@ -569,7 +419,7 @@ const addWorker = async () => {
}
const deleteWorker = async (id) => {
if (!confirm('Are you sure you want to delete this worker account?')) return
if (!confirm($t('areYouSureDeleteWorker'))) return
try {
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' })
if (workers.value.length === 1 && currentPage.value > 1) {
@@ -578,7 +428,7 @@ const deleteWorker = async (id) => {
await fetchWorkers(currentPage.value)
}
} catch (err) {
errorMessage.value = 'Failed to delete worker.'
errorMessage.value = $t('failedToDeleteWorker')
console.error(err)
}
}
@@ -602,16 +452,16 @@ const createTag = async () => {
}
const deleteTag = async (tagId) => {
if (!confirm('Are you sure you want to delete this tag? This will remove it from all workers.'))
if (!confirm($t('areYouSureDeleteTag'))) return
return
try {
await apiFetch(`/api/managers/tags/${tagId}`, { method: 'DELETE' })
allTags.value = allTags.value.filter((tag) => tag.id !== tagId)
// Also re-fetch workers to update their tag display if any had this tag
fetchWorkers(currentPage.value)
alert('Tag deleted successfully.')
alert($t('tagDeleted'))
} catch (err) {
alert(err.message || 'Failed to delete tag.')
alert(err.message || $t('failedToDeleteTag'))
console.error(err)
}
}
@@ -678,7 +528,7 @@ const toggleTagForSelection = async (tagId) => {
})
}
} catch (err) {
alert('Failed to update tags. Please try again.')
alert($t('failedToUpdateTags'))
console.error(err)
}
}
+40 -72
View File
@@ -1,119 +1,87 @@
<template>
<div class="flex flex-col gap-8">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">Create New QR Code</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('createQrCode') }}</h2>
<div class="flex flex-col sm:flex-row items-end gap-4 mb-6">
<div class="flex flex-col gap-2 flex-grow w-full">
<label for="qr-name" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>QR Code Name</label
>
<input
type="text"
id="qr-name"
v-model="newQrName"
placeholder="e.g., 'West Gate Entrance'"
<label for="qr-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('qrCodeName')
}}</label>
<input type="text" id="qr-name" v-model="newQrName" :placeholder="$t('qrNamePlaceholder')"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
@keyup.enter="addQrCode"
/>
@keyup.enter="addQrCode" />
</div>
<button
@click="addQrCode"
:disabled="!newQrName"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto"
>
Create
<button @click="addQrCode" :disabled="!newQrName"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
{{ $t('create') }}
</button>
</div>
<div
v-if="newlyGeneratedQr"
class="text-center p-8 mt-6 bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg"
>
<div v-if="newlyGeneratedQr"
class="text-center p-8 mt-6 bg-gray-50 dark:bg-gray-700 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">New Code Created!</h3>
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">{{ $t('newCodeCreated') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">
Save this image or use the ID below. This will disappear on refresh.
{{ $t('saveQrInstruction') }}
</p>
</div>
<div
class="inline-block mb-4 font-mono bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm"
>
<span>ID: {{ newlyGeneratedQr.id }}</span>
class="inline-block mb-4 font-mono bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm">
<span>{{ $t('id') }}: {{ newlyGeneratedQr.id }}</span>
</div>
<canvas
ref="newQrCanvas"
class="max-w-full h-auto border-4 border-gray-200 dark:border-gray-600 rounded-lg mx-auto"
></canvas>
<canvas ref="newQrCanvas"
class="max-w-full h-auto border-4 border-gray-200 dark:border-gray-600 rounded-lg mx-auto"></canvas>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">Existing QR Codes</h2>
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('existingQrCodes') }}</h2>
<div class="overflow-x-auto">
<table class="min-w-[600px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Name
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('name') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('status') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Status
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right"
>
Actions
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
{{ $t('actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="qr in qrCodes"
:key="qr.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<tr v-for="qr in qrCodes" :key="qr.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ qr.name }}</td>
<td class="px-4 py-3">
<span
class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase"
:class="
qr.is_active
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase" :class="qr.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-200'
"
>
{{ qr.is_active ? 'Active' : 'Inactive' }}
">
{{ $t(qr.is_active ? 'active' : 'inactive') }}
</span>
</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button
@click="downloadQrCode(qr)"
<button @click="downloadQrCode(qr)"
class="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
title="Download QR Code"
>
title="Download QR Code">
<span class="text-base"></span> Download
</button>
<button
@click="toggleQrStatus(qr)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
>
{{ qr.is_active ? 'Deactivate' : 'Activate' }}
<button @click="toggleQrStatus(qr)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t(qr.is_active ? 'deactivate' : 'activate') }}
</button>
<button
@click="deleteQrCode(qr.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200"
>
Delete
<button @click="deleteQrCode(qr.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('delete') }}
</button>
</td>
</tr>
<tr v-if="qrCodes.length === 0">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
No QR codes found. Create one above!
{{ $t('noQrCodesFound') }}
</td>
</tr>
</tbody>
@@ -200,7 +168,7 @@ const toggleQrStatus = async (qr) => {
}
const deleteQrCode = async (id) => {
if (!confirm('Are you sure you want to delete this QR code? This cannot be undone.')) {
if (!confirm($t('deleteQrConfirm'))) {
return
}
try {
@@ -231,7 +199,7 @@ const downloadQrCode = async (qr) => {
link.click()
document.body.removeChild(link)
} catch {
alert('Sorry, the QR code could not be downloaded.')
alert($t('qrDownloadError'))
}
}
</script>
+20
View File
@@ -0,0 +1,20 @@
console.log("[DEBUG] i18n.js loaded!"); // very top
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';
import ms from './locales/ms.json';
console.log("[DEBUG] en.json:", en);
console.log("[DEBUG] ms.json:", ms);
const i18n = createI18n({
legacy: false,
locale: 'en', // Default to English
fallbackLocale: 'en',
messages: { en, ms }
});
console.log("[DEBUG] i18n instance created:", i18n);
export default i18n;
console.log("[DEBUG] i18n.js export complete");
+184
View File
@@ -0,0 +1,184 @@
{
"appTitle": "Clock-In/Out System",
"logout": "Logout",
"login": "Login",
"username": "Username",
"password": "Password",
"loggingIn": "Logging in...",
"language": "Language",
"failedConnection": "Failed to connect to the server.",
"invalidToken": "Invalid token received from server.",
"english": "English",
"malay": "Malay",
"yourStatus": "Your Status",
"clockedIn": "Clocked In",
"clockedOut": "Clocked Out",
"clockIn": "Clock In",
"clockOut": "Clock Out",
"clock_in": "Clock In",
"clock_out": "Clock Out",
"scanToClock": "Scan to Clock {action}",
"in": "In",
"out": "Out",
"cancel": "Cancel",
"viewMyClockHistory": "View My Clock History",
"changeMyPassword": "Change My Password",
"myClockHistory": "My Clock History",
"backToDashboard": "Back to Dashboard",
"noClockHistory": "You have no clocking history.",
"clockHistoryFetchFail": "Failed to fetch clock history:",
"viewClockHistory": "View My Clock History →",
"changePassword": "Change My Password →",
"successClockIn": "Successfully clocked in.",
"successClockOut": "Successfully clocked out.",
"qrFail": "Could not detect a QR code. Please try again.",
"geoFail": "Unable to retrieve your location: {message}. Please enable location services.",
"successClock": "Successfully clocked at {location}.",
"changePasswordTitle": "Change Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmNewPassword": "Confirm New Password",
"updating": "Updating...",
"tabPersonnel": "Personnel",
"tabAttendance": "Attendance",
"tabQrCodes": "QR Codes",
"uploadQrImage": "Upload QR Image",
"couldNotLoadWorkerInfo": "Could not load worker information",
"couldNotVerifyStatus": "Could not verify current status from server",
"successfullyClocked": "Successfully clocked {action} at",
"site": "site",
"errorOccurred": "Error occurred",
"unableToStartCamera": "Unable to start camera.",
"tryAgain": "Try Again",
"qrDetectedGettingLocation": "QR Code detected. Getting location...",
"geolocationNotSupported": "Geolocation is not supported by your browser.",
"unableToRetrieveLocation": "Unable to retrieve your location: {message}. Please enable location services.",
"qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.",
"updatePassword": "Update Password",
"passwordsNoMatch": "New passwords do not match.",
"passwordTooShort": "New password must be at least 6 characters long.",
"passwordUpdated": "Password updated successfully! You can now use your new password to log in.",
"passwordUpdateError": "An error occurred while updating the password.",
"attendanceLogFor": "Attendance Log for",
"addManualClockOut": "Add Manual Clock-Out",
"manualClockOutInstruction": "Use this form if the worker forgot to clock out. The last event must be a clock-in.",
"clockOutTime": "Clock-Out Time",
"reason": "Reason (e.g., \"Forgot to clock out\")",
"enterBriefNote": "Enter a brief note",
"addRecord": "Add Record",
"startDate": "Start Date",
"endDate": "End Date",
"filterRecords": "Filter Records",
"event": "Event",
"timestamp": "Timestamp",
"locationName": "Location Name",
"coordinates": "Coordinates",
"notes": "Notes",
"noRecordsFound": "No records found for this period.",
"showOnMap": "Show on map",
"nA": "N/A",
"pleaseSelectTimestamp": "Please select a timestamp for the clock-out.",
"pleaseProvideReason": "Please provide a reason/note for the manual entry.",
"manualClockOutSuccess": "Manual clock-out recorded successfully!",
"manualClockOutError": "An error occurred: {message}",
"selectWorkers": "1. Select Workers",
"searchWorkerPlaceholder": "Search for a worker...",
"selectAll": "Select All",
"addWorkersByTag": "Add all workers from a tag",
"chooseTag": "-- Choose a tag --",
"addByTag": "Add by Tag",
"selectedForReport": "Selected for Report ({count})",
"allWorkersSelected": "All Workers ({count}) Selected",
"noWorkersSelected": "No workers selected.",
"reportSettings": "2. Report Settings",
"monthlySalary": "Monthly Salary (RM)",
"salaryAppliedNote": "Applied to all selected workers.",
"salaryPlaceholder": "e.g., 3000",
"otFactors": "OT Factors",
"weekendFactor": "Weekend Factor",
"holidayFactor": "Holiday Factor",
"selectPublicHolidays": "Select Public Holidays",
"generateReport": "Generate Attendance & OT Report",
"overtimePaySummary": "Overtime Pay Summary",
"exportOtSummary": "Export OT Summary (CSV)",
"worker": "Worker",
"totalHoursWorked": "Total Hours Worked",
"totalOtPay": "Total OT Pay (RM)",
"rawAttendanceData": "Raw Attendance Data",
"loadingReport": "Loading Report...",
"tagLoadError": "Could not load workers for the selected tag.",
"generateReportError": "Please select workers, set valid date range, and enter a salary.",
"reportGenerationError": "An error occurred while generating the report.",
"addNewUser": "Add New User",
"fullName": "Full Name",
"egJohnSmith": "e.g. John Smith",
"egJsmith": "e.g. jsmith",
"eg123456": "e.g. 123456",
"asManager": "As Manager",
"adding": "Adding...",
"addUser": "Add User",
"manageTags": "Manage Tags",
"createNewTag": "Create New Tag",
"egTeam": "e.g. Team",
"createTag": "Create Tag",
"tags": "Tags",
"workerRoster": "Worker Roster",
"searchByNameOrUsername": "Search by name or username",
"filterByTag": "Filter by tag",
"clearFilter": "Clear filter",
"dateJoined": "Date Joined",
"actions": "Actions",
"editTags": "Edit Tags",
"viewRecords": "View Records",
"delete": "Delete",
"loadingWorkers": "Loading workers...",
"noWorkersFound": "No workers found.",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {current} of {total}",
"noTagsAvailable": "No tags available.",
"done": "Done",
"bulkEditTags": "Bulk Edit Tags",
"clearSelection": "Clear Selection",
"forUser": "For user",
"savePassword": "Save Password",
"saving": "Saving...",
"failedToUpdateTags": "Failed to update tags. Please try again.",
"tagDeleted": "Tag deleted successfully.",
"failedToFetchWorkers": "Failed to fetch workers.",
"failedToLoadPageData": "Failed to load page data.",
"errorAddingUser": "An error occurred while adding the user.",
"failedToDeleteWorker": "Failed to delete worker.",
"areYouSureDeleteWorker": "Are you sure you want to delete this worker account?",
"areYouSureDeleteTag": "Are you sure you want to delete this tag? This will remove it from all workers.",
"failedToDeleteTag": "Failed to delete tag.",
"passwordsDoNotMatch": "Passwords do not match.",
"createQrCode": "Create New QR Code",
"qrCodeName": "QR Code Name",
"qrNamePlaceholder": "e.g., 'West Gate Entrance'",
"create": "Create",
"newCodeCreated": "New Code Created!",
"saveQrInstruction": "Save this image or use the ID below. This will disappear on refresh.",
"id": "ID",
"existingQrCodes": "Existing QR Codes",
"name": "Name",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"deactivate": "Deactivate",
"activate": "Activate",
"download": "Download",
"noQrCodesFound": "No QR codes found. Create one above!",
"deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.",
"qrDownloadError": "Sorry, the QR code could not be downloaded."
}
+183
View File
@@ -0,0 +1,183 @@
{
"appTitle": "Sistem Masuk/Keluar Kerja",
"logout": "Log Keluar",
"login": "Log Masuk",
"username": "Nama Pengguna",
"password": "Kata Laluan",
"loggingIn": "Sedang log masuk...",
"language": "Bahasa",
"failedConnection": "Gagal untuk berhubung dengan pelayan.",
"invalidToken": "Token tidak sah diterima dari pelayan.",
"english": "Bahasa Inggeris",
"malay": "Bahasa Melayu",
"yourStatus": "Status Anda",
"clockedIn": "Sudah Masuk",
"clockedOut": "Sudah Keluar",
"clockIn": "Masuk Kerja",
"clockOut": "Keluar Kerja",
"clock_in": "Masuk Kerja",
"clock_out": "Keluar Kerja",
"scanToClock": "Imbas untuk {action} Kerja",
"in": "Masuk",
"out": "Keluar",
"cancel": "Batal",
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
"changeMyPassword": "Tukar Kata Laluan Saya",
"myClockHistory": "Sejarah Kehadiran Saya",
"backToDashboard": "Kembali ke Papan Pemuka",
"noClockHistory": "Tiada rekod kehadiran.",
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
"viewClockHistory": "Lihat Sejarah Kehadiran Saya →",
"changePassword": "Tukar Kata Laluan Saya →",
"successClockIn": "Berjaya masuk kerja.",
"successClockOut": "Berjaya keluar kerja.",
"qrFail": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
"geoFail": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
"successClock": "Berjaya daftar di {location}.",
"changePasswordTitle": "Tukar Kata Laluan",
"currentPassword": "Kata Laluan Semasa",
"newPassword": "Kata Laluan Baharu",
"confirmNewPassword": "Sahkan Kata Laluan Baharu",
"updating": "Mengemaskini...",
"tabPersonnel": "Personel",
"tabAttendance": "Kehadiran",
"tabQrCodes": "Kod QR",
"uploadQrImage": "Muat Naik Imej QR",
"couldNotLoadWorkerInfo": "Tidak dapat memuatkan maklumat pekerja",
"couldNotVerifyStatus": "Tidak dapat mengesahkan status semasa dari pelayan",
"successfullyClocked": "Berjaya {action} di",
"site": "tapak",
"errorOccurred": "Ralat telah berlaku",
"unableToStartCamera": "Tidak dapat menghidupkan kamera.",
"tryAgain": "Cuba Lagi",
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
"updatePassword": "Kemaskini Kata Laluan",
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
"passwordUpdated": "Kata laluan berjaya dikemaskini! Anda boleh guna kata laluan baharu untuk log masuk.",
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
"attendanceLogFor": "Log Kehadiran untuk",
"addManualClockOut": "Tambah Clock-Out Manual",
"manualClockOutInstruction": "Gunakan borang ini jika pekerja lupa untuk clock-out. Acara terakhir mesti clock-in.",
"clockOutTime": "Masa Clock-Out",
"reason": "Sebab (cth: \"Lupa clock-out\")",
"enterBriefNote": "Masukkan nota ringkas",
"addRecord": "Tambah Rekod",
"startDate": "Tarikh Mula",
"endDate": "Tarikh Tamat",
"filterRecords": "Tapis Rekod",
"event": "Acara",
"timestamp": "Cap Masa",
"locationName": "Nama Lokasi",
"coordinates": "Koordinat",
"notes": "Nota",
"noRecordsFound": "Tiada rekod untuk tempoh ini.",
"showOnMap": "Papar di peta",
"nA": "Tiada",
"pleaseSelectTimestamp": "Sila pilih cap masa untuk clock-out.",
"pleaseProvideReason": "Sila berikan sebab/nota untuk kemasukan manual.",
"manualClockOutSuccess": "Clock-out manual berjaya direkod!",
"manualClockOutError": "Ralat berlaku: {message}",
"selectWorkers": "1. Pilih Pekerja",
"searchWorkerPlaceholder": "Cari pekerja...",
"selectAll": "Pilih Semua",
"addWorkersByTag": "Tambah semua pekerja berdasarkan tag",
"chooseTag": "-- Pilih tag --",
"addByTag": "Tambah melalui Tag",
"selectedForReport": "Dipilih untuk Laporan ({count})",
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
"noWorkersSelected": "Tiada pekerja dipilih.",
"reportSettings": "2. Tetapan Laporan",
"monthlySalary": "Gaji Bulanan (RM)",
"salaryAppliedNote": "Diguna untuk semua pekerja yang dipilih.",
"salaryPlaceholder": "cth: 3000",
"otFactors": "Faktor OT",
"weekendFactor": "Faktor Hujung Minggu",
"holidayFactor": "Faktor Cuti Umum",
"selectPublicHolidays": "Pilih Cuti Umum",
"generateReport": "Jana Laporan Kehadiran & OT",
"overtimePaySummary": "Ringkasan Bayaran OT",
"exportOtSummary": "Eksport Ringkasan OT (CSV)",
"worker": "Pekerja",
"totalHoursWorked": "Jumlah Jam Bekerja",
"totalOtPay": "Jumlah Bayaran OT (RM)",
"rawAttendanceData": "Data Kehadiran Mentah",
"loadingReport": "Memuatkan Laporan...",
"tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.",
"generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.",
"reportGenerationError": "Ralat semasa menjana laporan.",
"addNewUser": "Tambah Pengguna Baharu",
"fullName": "Nama Penuh",
"egJohnSmith": "cth. John Smith",
"egJsmith": "cth. jsmith",
"eg123456": "cth. 123456",
"asManager": "Sebagai Pengurus",
"adding": "Sedang menambah...",
"addUser": "Tambah Pengguna",
"manageTags": "Urus Tag",
"createNewTag": "Cipta Tag Baharu",
"egTeam": "cth. Pasukan",
"createTag": "Cipta Tag",
"tags": "Tag",
"workerRoster": "Senarai Pekerja",
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
"filterByTag": "Tapis mengikut tag",
"clearFilter": "Padam tapisan",
"dateJoined": "Tarikh Sertai",
"actions": "Tindakan",
"editTags": "Sunting Tag",
"viewRecords": "Lihat Rekod",
"delete": "Padam",
"loadingWorkers": "Memuatkan pekerja...",
"noWorkersFound": "Tiada pekerja dijumpai.",
"previous": "Sebelum",
"next": "Seterusnya",
"pageOf": "Halaman {current} daripada {total}",
"noTagsAvailable": "Tiada tag tersedia.",
"done": "Selesai",
"bulkEditTags": "Sunting Tag Secara Berkumpulan",
"clearSelection": "Padam Pilihan",
"forUser": "Untuk pengguna",
"savePassword": "Simpan Kata Laluan",
"saving": "Menyimpan...",
"failedToUpdateTags": "Gagal mengemas kini tag. Sila cuba lagi.",
"tagDeleted": "Tag berjaya dipadam.",
"failedToFetchWorkers": "Gagal memuatkan pekerja.",
"failedToLoadPageData": "Gagal memuatkan data halaman.",
"errorAddingUser": "Ralat semasa menambah pengguna.",
"failedToDeleteWorker": "Gagal memadam pekerja.",
"areYouSureDeleteWorker": "Adakah anda pasti mahu memadam akaun pekerja ini?",
"areYouSureDeleteTag": "Adakah anda pasti mahu memadam tag ini? Ia akan dikeluarkan daripada semua pekerja.",
"failedToDeleteTag": "Gagal memadam tag.",
"passwordsDoNotMatch": "Kata laluan tidak sepadan.",
"createQrCode": "Cipta Kod QR Baharu",
"qrCodeName": "Nama Kod QR",
"qrNamePlaceholder": "cth: 'Pintu Masuk Barat'",
"create": "Cipta",
"newCodeCreated": "Kod Baharu Telah Dicipta!",
"saveQrInstruction": "Simpan imej ini atau gunakan ID di bawah. Ini akan hilang selepas segar semula.",
"id": "ID",
"existingQrCodes": "Kod QR Sedia Ada",
"name": "Nama",
"status": "Status",
"active": "Aktif",
"inactive": "Tidak Aktif",
"deactivate": "Nyahaktif",
"activate": "Aktifkan",
"download": "Muat Turun",
"noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!",
"deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.",
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun."
}
+3 -2
View File
@@ -1,12 +1,13 @@
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
app.use(router)
app.use(i18n)
app.mount('#app')
+58 -103
View File
@@ -2,88 +2,57 @@
<div class="max-w-4xl mx-auto px-4 py-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="mb-6">
<router-link to="/manager/dashboard" class="text-blue-600 hover:text-blue-800 font-medium"
> Back to Dashboard</router-link
>
<router-link to="/manager/dashboard" class="text-blue-600 hover:text-blue-800 font-medium">
{{ $t('backToDashboard') }}
</router-link>
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mt-2">
Attendance Log for {{ workerName }}
{{ $t('attendanceLogFor') }} {{ workerName }}
</h2>
</div>
<div
class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6"
>
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<h3 class="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-1">
Add Manual Clock-Out
{{ $t('addManualClockOut') }}
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-4">
Use this form if the worker forgot to clock out. The last event must be a clock-in.
{{ $t('manualClockOutInstruction') }}
</p>
<div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex flex-col gap-2 flex-grow w-full">
<label
for="manual-timestamp"
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Clock-Out Time</label
>
<input
type="datetime-local"
id="manual-timestamp"
v-model="manualClockOut.timestamp"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="manual-timestamp" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
$t('clockOutTime') }}</label>
<input type="datetime-local" id="manual-timestamp" v-model="manualClockOut.timestamp"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div class="flex flex-col gap-2 flex-grow w-full">
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Reason (e.g., "Forgot to clock out")</label
>
<input
type="text"
id="manual-notes"
v-model="manualClockOut.notes"
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('reason')
}}</label>
<input type="text" id="manual-notes" v-model="manualClockOut.notes"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Enter a brief note"
/>
:placeholder="$t('enterBriefNote')" />
</div>
<button
@click="addManualClockOut"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
Add Record
<button @click="addManualClockOut"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
{{ $t('addRecord') }}
</button>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>Start Date</label
>
<input
type="date"
id="start-date"
v-model="filters.startDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate')
}}</label>
<input type="date" id="start-date" v-model="filters.startDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>End Date</label
>
<input
type="date"
id="end-date"
v-model="filters.endDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
<input type="date" id="end-date" v-model="filters.endDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<button
@click="fetchRecords"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
Filter Records
<button @click="fetchRecords"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
{{ $t('filterRecords') }}
</button>
</div>
@@ -91,44 +60,31 @@
<table class="min-w-[650px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Event
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('event') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Timestamp
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('timestamp') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Location Name
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('locationName') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Coordinates
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('coordinates') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider"
>
Notes
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('notes') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-if="!records.length">
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
No records found for this period.
{{ $t('noRecordsFound') }}
</td>
</tr>
<tr
v-for="record in records"
:key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<tr v-for="record in records" :key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3">
<span
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
@@ -136,27 +92,23 @@
'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out',
'bg-yellow-500': record.event_type === 'failed',
}"
>{{ record.event_type.replace('_', ' ') }}</span
>
}">
{{ record.event_type.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
<td class="px-4 py-3">
<a
v-if="record.latitude && record.longitude"
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 underline font-medium"
>
Show on map
<a v-if="record.latitude && record.longitude"
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`" target="_blank"
rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline font-medium">
{{ $t('showOnMap') }}
</a>
<span v-else class="text-gray-500 dark:text-gray-400">N/A</span>
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || 'N/A' }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
</tr>
</tbody>
</table>
@@ -168,8 +120,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { apiFetch } from '@/api.js'
const { t } = useI18n()
const route = useRoute()
const records = ref([])
const workerName = ref('')
@@ -221,11 +176,11 @@ const fetchRecords = async () => {
const addManualClockOut = async () => {
if (!manualClockOut.value.timestamp) {
alert('Please select a timestamp for the clock-out.')
alert(t('pleaseSelectTimestamp'))
return
}
if (!manualClockOut.value.notes) {
alert('Please provide a reason/note for the manual entry.')
alert(t('pleaseProvideReason'))
return
}
@@ -243,13 +198,13 @@ const addManualClockOut = async () => {
}),
})
alert('Manual clock-out recorded successfully!')
alert(t('manualClockOutSuccess'))
manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords()
} catch (err) {
console.error('Failed to submit manual clock-out:', err)
alert(`An error occurred: ${err.message}`)
alert(t('manualClockOutError', { msg: err.message }))
}
}
+29 -59
View File
@@ -1,77 +1,45 @@
<template>
<div class="max-w-md mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-gray-800 dark:text-white text-center mb-8">
Change Password
{{ $t('changePasswordTitle') }}
</h1>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<form @submit.prevent="handleChangePassword">
<div
v-if="successMessage"
class="bg-green-100 text-green-700 p-3 rounded-md text-center mb-4"
>
{{ successMessage }}
<div v-if="successMessage" class="bg-green-100 text-green-700 p-3 rounded-md text-center mb-4">
{{ $t(successMessage) }}
</div>
<div v-if="errorMessage" class="bg-red-100 text-red-700 p-3 rounded-md text-center mb-4">
{{ errorMessage }}
{{ $t(errorMessage) }}
</div>
<div class="flex flex-col gap-4">
<div>
<label
for="currentPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Current Password</label
>
<input
type="password"
id="currentPassword"
v-model="passwords.currentPassword"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="currentPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
$t('currentPassword') }}</label>
<input type="password" id="currentPassword" v-model="passwords.currentPassword" required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>New Password</label
>
<input
type="password"
id="newPassword"
v-model="passwords.newPassword"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
$t('newPassword') }}</label>
<input type="password" id="newPassword" v-model="passwords.newPassword" required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>Confirm New Password</label
>
<input
type="password"
id="confirmPassword"
v-model="passwords.confirmPassword"
required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
$t('confirmNewPassword') }}</label>
<input type="password" id="confirmPassword" v-model="passwords.confirmPassword" required
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<button
type="submit"
:disabled="loading"
class="w-full py-3 text-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200 disabled:opacity-50"
>
{{ loading ? 'Updating...' : 'Update Password' }}
<button type="submit" :disabled="loading"
class="w-full py-3 text-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200 disabled:opacity-50">
{{ loading ? $t('updating') : $t('updatePassword') }}
</button>
</div>
</form>
<router-link
to="/worker/dashboard"
class="block text-center mt-6 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline"
>
Back to Dashboard
<router-link to="/worker/dashboard"
class="block text-center mt-6 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline">
{{ $t('backToDashboard') }}
</router-link>
</div>
</div>
@@ -79,8 +47,11 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiFetch } from '@/api.js'
const { t } = useI18n()
const passwords = ref({
currentPassword: '',
newPassword: '',
@@ -95,11 +66,11 @@ const handleChangePassword = async () => {
successMessage.value = ''
if (passwords.value.newPassword !== passwords.value.confirmPassword) {
errorMessage.value = 'New passwords do not match.'
errorMessage.value = 'passwordsNoMatch'
return
}
if (passwords.value.newPassword.length < 6) {
errorMessage.value = 'New password must be at least 6 characters long.'
errorMessage.value = 'passwordTooShort'
return
}
@@ -112,11 +83,10 @@ const handleChangePassword = async () => {
newPassword: passwords.value.newPassword,
}),
})
successMessage.value =
'Password updated successfully! You can now use your new password to log in.'
successMessage.value = 'passwordUpdated'
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
} catch (err) {
errorMessage.value = err.message || 'An error occurred.'
errorMessage.value = err.message || 'passwordUpdateError'
} finally {
loading.value = false
}
+37 -34
View File
@@ -1,47 +1,37 @@
<template>
<div class="flex justify-center items-center min-h-[70vh] p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 w-full max-w-md">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6 text-center">Login</h2>
<!-- Title -->
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6 text-center">
{{ t('login') }}
</h2>
<form @submit.prevent="handleLogin">
<!-- Username -->
<div class="mb-5">
<label
for="username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Username</label
>
<input
type="text"
id="username"
v-model="username"
<label for="username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t('username')
}}</label>
<input type="text" id="username" v-model="username" autocomplete="username"
class="block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
required
/>
required />
</div>
<!-- Password -->
<div class="mb-5">
<label
for="password"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>Password</label
>
<input
type="password"
id="password"
v-model="password"
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t('password')
}}</label>
<input type="password" id="password" v-model="password" autocomplete="current-password"
class="block w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-gray-900 dark:text-white focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
required
/>
required />
</div>
<!-- <p class="text-xs text-gray-500 dark:text-gray-400 text-center mb-6">
Hint: worker/password or manager/password
</p> -->
<button
type="submit"
<!-- Submit Button -->
<button type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-md text-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
>
{{ loading ? 'Logging in...' : 'Login' }}
:disabled="loading">
{{ loading ? t('loggingIn') : t('login') }}
</button>
<p v-if="error" class="text-red-600 text-center mt-4">{{ error }}</p>
<!-- Error -->
<p v-if="error" class="text-red-600 text-center mt-4">
{{ t(error) !== error ? t(error) : error }}
</p>
</form>
</div>
</div>
@@ -50,6 +40,11 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
// Debug
console.log("Current locale:", locale.value)
console.log("t('login') gives:", t('login'))
const router = useRouter()
const username = ref('')
@@ -82,13 +77,21 @@ const handleLogin = async () => {
router.push('/manager/dashboard')
}
} catch {
error.value = 'Invalid tokrouteren received from server.'
error.value = 'invalidToken'
}
} else {
// Use translation keys for known errors
if (data.message === 'Invalid token received from server.') {
error.value = 'invalidToken'
} else if (data.message === 'Failed to connect to the server.') {
error.value = 'failedConnection'
} else {
// You can map more backend messages here if needed
error.value = data.message
}
}
} catch {
error.value = 'Failed to connect to the server.'
error.value = 'failedConnection'
} finally {
loading.value = false
}
+16 -23
View File
@@ -1,43 +1,33 @@
<template>
<div class="max-w-full mx-auto px-4 py-4">
<div
class="flex flex-wrap justify-center gap-2 sm:gap-4 border-b-2 border-gray-200 dark:border-gray-700 mb-6 pb-2 relative z-10"
>
<button
@click="activeTab = 'personnel'"
:class="{
class="flex flex-wrap justify-center gap-2 sm:gap-4 border-b-2 border-gray-200 dark:border-gray-700 mb-6 pb-2 relative z-10">
<button @click="activeTab = 'personnel'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'personnel',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'personnel',
}"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"
>
Personnel
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabPersonnel') }}
</button>
<button
@click="activeTab = 'attendance'"
:class="{
<button @click="activeTab = 'attendance'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'attendance',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'attendance',
}"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"
>
Attendance
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabAttendance') }}
</button>
<button
@click="activeTab = 'qr'"
:class="{
<button @click="activeTab = 'qr'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'qr',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'qr',
}"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"
>
QR Codes
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabQrCodes') }}
</button>
</div>
<div class="tab-content">
@@ -50,21 +40,24 @@
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AttendanceReporting from '@/components/AttendanceReporting.vue'
import QrCodeManagement from '@/components/QrCodeManagement.vue'
import PersonnelManagement from '@/components/PersonnelManagement.vue'
const { t } = useI18n()
const activeTab = ref('personnel')
</script>
<style scoped>
/* Scrollbar hide styles are technically not needed for this component anymore */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
</style>
+39 -56
View File
@@ -2,16 +2,14 @@
<div class="max-w-md mx-auto px-4 py-8 flex flex-col gap-6">
<h1 class="text-3xl font-bold text-gray-800 dark:text-white text-center">{{ workerName }}</h1>
<div
class="flex items-center gap-6 p-6 rounded-lg shadow-md text-white"
:class="isClockedIn ? 'bg-green-500' : 'bg-red-500'"
>
<div class="flex items-center gap-6 p-6 rounded-lg shadow-md text-white"
:class="isClockedIn ? 'bg-green-500' : 'bg-red-500'">
<div class="text-4xl">
<span v-if="isClockedIn"></span>
<span v-else>🙏</span>
</div>
<div>
<p class="text-sm opacity-90">Your Status</p>
<p class="text-sm opacity-90">{{ $t('yourStatus') }}</p>
<h2 class="text-2xl font-bold">{{ clockStatus }}</h2>
</div>
</div>
@@ -25,61 +23,45 @@
</p>
<div v-if="!isScannerActive" class="flex flex-col gap-4">
<button
@click="startScanner"
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200"
>
<button @click="startScanner"
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-md transition-colors duration-200">
<span>📷</span>
<span>Scan to Clock {{ isClockedIn ? 'Out' : 'In' }}</span>
<span>{{ $t('scanToClock', { action: $t(isClockedIn ? 'out' : 'in') }) }}</span>
</button>
<!-- <button
@click="triggerFileUpload"
class="w-full py-4 text-xl flex items-center justify-center gap-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold rounded-md transition-colors duration-200"
>
<span>📁</span>
<span>Upload QR Image</span>
</button> -->
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
</div>
</div>
<div
id="qr-reader-container"
v-show="isScannerActive"
class="fixed inset-0 bg-gray-900 bg-opacity-70 flex flex-col items-center justify-center z-50 p-4"
>
<div
id="qr-reader"
class="w-full max-w-sm bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl"
></div>
<button
@click="stopScanner"
class="mt-4 bg-red-600 hover:bg-red-700 text-white font-semibold px-6 py-3 rounded-md transition-colors duration-200"
>
Cancel
<div id="qr-reader-container" v-show="isScannerActive"
class="fixed inset-0 bg-gray-900 bg-opacity-70 flex flex-col items-center justify-center z-50 p-4">
<div id="qr-reader" class="w-full max-w-sm bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl"></div>
<button @click="stopScanner"
class="mt-4 bg-red-600 hover:bg-red-700 text-white font-semibold px-6 py-3 rounded-md transition-colors duration-200">
{{ $t('cancel') }}
</button>
</div>
<router-link
to="/worker/history"
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200"
>View My Clock History </router-link
>
<router-link to="/worker/history"
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200">
{{ $t('viewMyClockHistory') }}
</router-link>
<router-link
to="/worker/change-password"
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200"
>Change My Password </router-link
>
<router-link to="/worker/change-password"
class="text-center text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white font-medium underline px-4 py-2 rounded-md transition-colors duration-200">
{{ $t('changeMyPassword') }}
</router-link>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { Html5Qrcode } from 'html5-qrcode'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Html5Qrcode } from 'html5-qrcode'
import { apiFetch } from '@/api.js'
const { t } = useI18n()
let html5QrCode = null
const fileInput = ref(null)
const router = useRouter()
@@ -92,7 +74,7 @@ const workerName = ref('')
const userId = sessionStorage.getItem('userId')
const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked Out'))
const clockStatus = computed(() => (isClockedIn.value ? t('clockedIn') : t('clockedOut')))
const fetchWorkerDetails = async () => {
try {
@@ -101,7 +83,7 @@ const fetchWorkerDetails = async () => {
workerName.value = data.full_name
}
} catch (err) {
errorMessage.value = `Could not load worker information: ${err.message}`
errorMessage.value = t('couldNotLoadWorkerInfo') + `: ${err.message}`
}
}
@@ -112,7 +94,7 @@ const fetchCurrentStatus = async () => {
isClockedIn.value = lastEvent.eventType === 'clock_in'
}
} catch (err) {
errorMessage.value = `Could not verify current status from server: ${err.message}`
errorMessage.value = t('couldNotVerifyStatus') + `: ${err.message}`
}
}
@@ -131,11 +113,9 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
})
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${
data.location || 'site'
}.`
successMessage.value = t('successfullyClocked', { action: t(eventType) }) + ` ${data.location || t('site')}.`
} catch (err) {
errorMessage.value = `Error: ${err.message}`
errorMessage.value = t('errorOccurred') + `: ${err.message}`
}
}
@@ -158,6 +138,7 @@ const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
}
const startScanner = () => {
isScannerActive.value = true
clearMessages()
@@ -167,7 +148,7 @@ const startScanner = () => {
const config = { fps: 10, qrbox: { width: 250, height: 250 } }
html5QrCode.start({ facingMode: 'environment' }, config, onScanSuccess, onScanFailure)
} catch {
errorMessage.value = `Unable to start camera. `
errorMessage.value = t('unableToStartCamera')
isScannerActive.value = false
}
}, 300)
@@ -179,7 +160,7 @@ const stopScanner = () => {
}
isScannerActive.value = false
}
// const triggerFileUpload = () => fileInput.value.click()
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
@@ -191,24 +172,26 @@ const handleFileUpload = (event) => {
.scanFile(file, true)
.then(onScanSuccess)
.catch(() => {
onScanFailure(`Try Again`)
onScanFailure(t('tryAgain'))
})
}
const onScanSuccess = (decodedText) => {
successMessage.value = `QR Code detected. Getting location...`
successMessage.value = t('qrDetectedGettingLocation')
stopScanner()
if (!navigator.geolocation) {
errorMessage.value = 'Geolocation is not supported by your browser.'
errorMessage.value = t('geolocationNotSupported')
return
}
navigator.geolocation.getCurrentPosition(
(position) => sendClockEvent(decodedText, position.coords.latitude, position.coords.longitude),
(geoError) =>
(errorMessage.value = `Unable to retrieve your location: ${geoError.message}. Please enable location services.`),
(errorMessage.value = t('unableToRetrieveLocation', { message: geoError.message })),
)
}
const onScanFailure = () => {
errorMessage.value = 'Could not detect a QR code. Please try again.'
errorMessage.value = t('qrNotDetectedTryAgain')
}
</script>
+11 -18
View File
@@ -1,31 +1,22 @@
<template>
<div class="max-w-3xl mx-auto px-4 py-8 bg-white dark:bg-gray-800 rounded-lg shadow">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-4">My Clock History</h2>
<router-link
to="/worker/dashboard"
class="text-blue-600 hover:text-blue-800 font-medium mb-6 inline-block"
> Back to Dashboard</router-link
>
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-4">{{ $t('myClockHistory') }}</h2>
<router-link to="/worker/dashboard" class="text-blue-600 hover:text-blue-800 font-medium mb-6 inline-block"> {{
$t('backToDashboard') }}</router-link>
<div v-if="!clockHistory.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
You have no clocking history.
{{ $t('noClockHistory') }}
</div>
<div class="space-y-4">
<div
v-for="event in clockHistory"
:key="event.id"
class="bg-gray-50 dark:bg-gray-700 rounded-lg shadow-sm p-4"
>
<div v-for="event in clockHistory" :key="event.id" class="bg-gray-50 dark:bg-gray-700 rounded-lg shadow-sm p-4">
<div class="flex items-center gap-3 flex-wrap">
<div
class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
<div class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
:class="{
'bg-green-500': event.event_type === 'clock_in',
'bg-red-500': event.event_type === 'clock_out',
}"
>
{{ event.event_type.replace('_', ' ') }}
}">
{{ $t(event.event_type) }}
</div>
<div class="text-gray-800 dark:text-white text-sm font-medium">
{{ new Date(event.timestamp).toLocaleString() }}
@@ -42,8 +33,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { apiFetch } from '@/api.js'
const { t } = useI18n()
const router = useRouter()
const clockHistory = ref([])
const userId = sessionStorage.getItem('userId')
@@ -59,7 +52,7 @@ onMounted(async () => {
clockHistory.value = data.filter(event => event.event_type !== 'failed');
}
} catch (error) {
console.error('Failed to fetch clock history:', error)
console.error(t('clockHistoryFetchFail'), error)
}
})
</script>