feat(i18n): 添加多语言支持并实现国际化功能
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
Generated
+65
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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">
|
||||
<
|
||||
</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">
|
||||
>
|
||||
</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('')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user