more progress in hrm plugin

This commit is contained in:
Hesabix 2025-08-23 15:47:57 +00:00
parent 1418591120
commit 5334b1fddb
9 changed files with 1375 additions and 142 deletions

View file

@ -281,13 +281,206 @@ class AttendanceController extends AbstractController
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.'); throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
} }
// دریافت نوع پرسنل "کارمند" یا "employee" $params = [];
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'employee']); if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$search = $params['search'] ?? '';
$page = $params['page'] ?? 1;
$limit = $params['limit'] ?? 20;
// دریافت نوع پرسنل "کارمند" - کد صحیح در دیتابیس: emplyee
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'emplyee']);
if (!$employeeType) { if (!$employeeType) {
// اگر نوع "employee" وجود نداشت، نوع "کارمند" را جستجو کن // اگر نوع "emplyee" وجود نداشت، نوع "کارمند" را جستجو کن
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'کارمند']); $employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'کارمند']);
} }
$qb = $this->entityManager->createQueryBuilder();
$qb->select('p')
->from(Person::class, 'p')
->where('p.bid = :bid')
->setParameter('bid', $acc['bid']);
// فقط پرسنل‌هایی که نوع پرسنل دارند
if ($employeeType) {
$qb->join('p.type', 't')
->andWhere('t = :employeeType')
->setParameter('employeeType', $employeeType);
} else {
// اگر نوع کارمند پیدا نشد، حداقل پرسنل‌هایی که نوع دارند را برگردان
$qb->join('p.type', 't');
}
// اعمال فیلتر جستجو
if (!empty($search)) {
$qb->andWhere('p.nikename LIKE :search OR p.name LIKE :search OR p.code LIKE :search')
->setParameter('search', '%' . $search . '%');
}
$qb->orderBy('p.nikename', 'ASC');
// محاسبه تعداد کل
$countQb = clone $qb;
$totalCount = $countQb->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
// اعمال صفحه‌بندی
$qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$employees = $qb->getQuery()->getResult();
$result = [];
foreach ($employees as $employee) {
$result[] = [
'id' => $employee->getId(),
'name' => $employee->getNikename(),
'code' => $employee->getCode(),
'mobile' => $employee->getMobile(),
'tel' => $employee->getTel(),
'email' => $employee->getEmail(),
'company' => $employee->getCompany(),
'address' => $employee->getAddress(),
];
}
return $this->json([
'items' => $result,
'total' => $totalCount,
'page' => $page,
'limit' => $limit
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/employees/search', name: 'hrm_attendance_employees_search', methods: ['POST'])]
public function searchEmployees(Request $request, Access $access): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$search = $params['search'] ?? '';
$page = $params['page'] ?? 1;
$limit = $params['limit'] ?? 10;
// فیلترهای پیشرفته
$code = $params['code'] ?? '';
$mobile = $params['mobile'] ?? '';
$company = $params['company'] ?? '';
$email = $params['email'] ?? '';
// دریافت نوع پرسنل "کارمند" - کد صحیح در دیتابیس: emplyee
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'emplyee']);
if (!$employeeType) {
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'کارمند']);
}
$qb = $this->entityManager->createQueryBuilder();
$qb->select('p')
->from(Person::class, 'p')
->where('p.bid = :bid')
->setParameter('bid', $acc['bid']);
// فقط پرسنل‌هایی که نوع پرسنل دارند
if ($employeeType) {
$qb->join('p.type', 't')
->andWhere('t = :employeeType')
->setParameter('employeeType', $employeeType);
} else {
// اگر نوع کارمند پیدا نشد، حداقل پرسنل‌هایی که نوع دارند را برگردان
$qb->join('p.type', 't');
}
// اعمال فیلتر جستجو عمومی
if (!empty($search)) {
$qb->andWhere('p.nikename LIKE :search OR p.name LIKE :search OR p.code LIKE :search OR p.mobile LIKE :search')
->setParameter('search', '%' . $search . '%');
}
// اعمال فیلترهای پیشرفته
if (!empty($code)) {
$qb->andWhere('p.code LIKE :code')
->setParameter('code', '%' . $code . '%');
}
if (!empty($mobile)) {
$qb->andWhere('p.mobile LIKE :mobile')
->setParameter('mobile', '%' . $mobile . '%');
}
if (!empty($company)) {
$qb->andWhere('p.company LIKE :company')
->setParameter('company', '%' . $company . '%');
}
if (!empty($email)) {
$qb->andWhere('p.email LIKE :email')
->setParameter('email', '%' . $email . '%');
}
$qb->orderBy('p.nikename', 'ASC');
// محاسبه تعداد کل
$countQb = clone $qb;
$totalCount = $countQb->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
// اعمال صفحه‌بندی
$qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$employees = $qb->getQuery()->getResult();
$result = [];
foreach ($employees as $employee) {
$result[] = [
'id' => $employee->getId(),
'name' => $employee->getNikename(),
'code' => $employee->getCode(),
'mobile' => $employee->getMobile(),
'tel' => $employee->getTel(),
'email' => $employee->getEmail(),
'company' => $employee->getCompany(),
'address' => $employee->getAddress(),
'fullName' => $employee->getName(),
];
}
return $this->json([
'items' => $result,
'total' => $totalCount,
'page' => $page,
'limit' => $limit
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/employees/test', name: 'hrm_attendance_employees_test', methods: ['GET'])]
public function testEmployees(Access $access): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
// دریافت نوع پرسنل "کارمند"
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'emplyee']);
$qb = $this->entityManager->createQueryBuilder(); $qb = $this->entityManager->createQueryBuilder();
$qb->select('p') $qb->select('p')
->from(Person::class, 'p') ->from(Person::class, 'p')
@ -298,10 +491,11 @@ class AttendanceController extends AbstractController
$qb->join('p.type', 't') $qb->join('p.type', 't')
->andWhere('t = :employeeType') ->andWhere('t = :employeeType')
->setParameter('employeeType', $employeeType); ->setParameter('employeeType', $employeeType);
} else {
$qb->join('p.type', 't');
} }
$qb->orderBy('p.nikename', 'ASC'); $qb->setMaxResults(5);
$employees = $qb->getQuery()->getResult(); $employees = $qb->getQuery()->getResult();
$result = []; $result = [];
@ -310,10 +504,18 @@ class AttendanceController extends AbstractController
'id' => $employee->getId(), 'id' => $employee->getId(),
'name' => $employee->getNikename(), 'name' => $employee->getNikename(),
'code' => $employee->getCode(), 'code' => $employee->getCode(),
'bid' => $employee->getBid()->getId(),
'hasType' => $employee->getType()->count() > 0
]; ];
} }
return $this->json($result); return $this->json([
'business_id' => $acc['bid'],
'employee_type_found' => $employeeType ? true : false,
'employee_type_code' => $employeeType ? $employeeType->getCode() : null,
'total_employees' => count($result),
'employees' => $result
]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500); return $this->json(['error' => $e->getMessage()], 500);

View file

@ -0,0 +1,469 @@
<template>
<div>
<v-menu v-model="menu" :close-on-content-click="false" max-width="600">
<template v-slot:activator="{ props }">
<v-text-field
v-bind="props"
:model-value="displayValue"
@update:model-value="updateDisplayValue"
variant="outlined"
:label="label"
class="my-0"
prepend-inner-icon="mdi-account-search"
clearable
@click:clear="clearSelection"
:loading="loading"
@keydown.enter="handleEnter"
density="comfortable"
style="font-size: 0.7rem;"
:error="hasError"
:error-messages="errorMessage"
>
<template v-slot:append-inner>
<v-icon>{{ menu ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="500" max-width="600" class="search-card">
<v-card-title class="d-flex align-center justify-space-between pa-4">
<span class="text-h6">جستجوی پرسنل</span>
<v-btn icon="mdi-close" variant="text" @click="menu = false" />
</v-card-title>
<v-card-text class="pa-4">
<!-- فیلترهای پیشرفته -->
<v-expansion-panels variant="accordion" class="mb-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-filter-variant</v-icon>
فیلترهای پیشرفته
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
v-model="advancedFilters.code"
label="کد پرسنل"
variant="outlined"
density="compact"
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="advancedFilters.mobile"
label="شماره موبایل"
variant="outlined"
density="compact"
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="advancedFilters.company"
label="شرکت"
variant="outlined"
density="compact"
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="advancedFilters.email"
label="ایمیل"
variant="outlined"
density="compact"
clearable
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="d-flex justify-end">
<v-btn
color="primary"
variant="outlined"
size="small"
@click="applyAdvancedFilters"
:loading="loading"
>
اعمال فیلترها
</v-btn>
<v-btn
class="ms-2"
variant="outlined"
size="small"
@click="clearAdvancedFilters"
>
پاک کردن
</v-btn>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- فیلد جستجو -->
<v-text-field
v-model="searchQuery"
label="جستجو در نام، کد، موبایل..."
variant="outlined"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
@keyup.enter="searchEmployees"
@click:clear="searchEmployees"
/>
<!-- نتایج جستجو -->
<template v-if="!loading">
<v-list density="compact" class="list-container mt-4">
<template v-if="filteredItems.length > 0">
<v-list-item
v-for="item in filteredItems"
:key="item.id"
@click="selectItem(item)"
class="mb-2 search-result-item"
:class="{ 'selected-item': selectedItem?.id === item.id }"
>
<template v-slot:prepend>
<v-avatar size="40" color="primary" class="text-white">
<span class="text-h6">{{ getInitials(item.name) }}</span>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium">
{{ item.name }}
<span class="text-caption text-grey-darken-1 ms-2">({{ item.code }})</span>
</v-list-item-title>
<v-list-item-subtitle>
<div class="d-flex align-center mt-1">
<v-icon size="small" color="primary" class="mr-1">mdi-cellphone</v-icon>
<span class="text-caption">{{ item.mobile || 'بدون موبایل' }}</span>
</div>
<div v-if="item.company" class="d-flex align-center mt-1">
<v-icon size="small" color="secondary" class="mr-1">mdi-domain</v-icon>
<span class="text-caption">{{ item.company }}</span>
</div>
<div v-if="item.email" class="d-flex align-center mt-1">
<v-icon size="small" color="info" class="mr-1">mdi-email</v-icon>
<span class="text-caption">{{ item.email }}</span>
</div>
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
size="small"
color="success"
class="employee-chip"
>
کارمند
</v-chip>
</template>
</v-list-item>
</template>
<template v-else>
<v-list-item>
<v-list-item-title class="text-center text-grey">
<v-icon size="large" color="grey" class="mb-2">mdi-account-search</v-icon>
<div>نتیجهای یافت نشد</div>
<div class="text-caption">لطفاً عبارت جستجو را تغییر دهید</div>
</v-list-item-title>
</v-list-item>
</template>
</v-list>
<!-- صفحهبندی -->
<div v-if="totalItems > itemsPerPage" class="d-flex justify-center mt-4">
<v-pagination
v-model="currentPage"
:length="Math.ceil(totalItems / itemsPerPage)"
:total-visible="5"
@update:model-value="onPageChange"
/>
</div>
</template>
<v-progress-circular
v-else
indeterminate
color="primary"
class="d-flex mx-auto my-8"
></v-progress-circular>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'HemployeeAdvancedSearch',
props: {
modelValue: {
type: [Object, Number],
default: null
},
label: {
type: String,
default: 'جستجوی پرسنل'
},
returnObject: {
type: Boolean,
default: false
},
rules: {
type: Array,
default: () => []
}
},
data() {
return {
selectedItem: null,
items: [],
loading: false,
menu: false,
searchQuery: '',
totalItems: 0,
currentPage: 1,
itemsPerPage: 10,
searchTimeout: null,
advancedFilters: {
code: '',
mobile: '',
company: '',
email: ''
}
};
},
computed: {
filteredItems() {
return Array.isArray(this.items) ? this.items : [];
},
displayValue() {
if (this.menu) {
return this.searchQuery;
}
return this.selectedItem ? this.selectedItem.name : '';
},
hasError() {
if (this.returnObject) {
return !(this.modelValue && typeof this.modelValue === 'object' && this.modelValue.id);
} else {
return !(this.modelValue && typeof this.modelValue === 'number');
}
},
errorMessage() {
if (this.hasError) {
return 'انتخاب پرسنل الزامی است';
}
return '';
}
},
watch: {
modelValue: {
handler(newVal) {
if (this.returnObject) {
this.selectedItem = newVal;
} else {
this.selectedItem = this.items.find(item => item.id === newVal);
}
},
immediate: true
},
searchQuery: {
handler(newVal) {
this.currentPage = 1;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
this.searchEmployees();
}, 500);
}
}
},
mounted() {
this.searchEmployees();
},
methods: {
updateDisplayValue(value) {
this.searchQuery = value;
if (!value) {
this.clearSelection();
}
},
async searchEmployees() {
this.loading = true;
try {
const params = {
search: this.searchQuery,
page: this.currentPage,
limit: this.itemsPerPage,
...this.advancedFilters
};
const response = await axios.post('/api/hrm/attendance/employees/search', params);
if (response.data && response.data.items) {
this.items = response.data.items;
this.totalItems = response.data.total || response.data.items.length;
} else {
this.items = [];
this.totalItems = 0;
}
if (this.modelValue) {
if (this.returnObject) {
this.selectedItem = this.modelValue;
} else {
this.selectedItem = this.items.find(item => item.id === this.modelValue);
}
}
} catch (error) {
console.error('خطا در جستجوی پرسنل:', error);
this.items = [];
this.totalItems = 0;
} finally {
this.loading = false;
}
},
applyAdvancedFilters() {
this.currentPage = 1;
this.searchEmployees();
},
clearAdvancedFilters() {
this.advancedFilters = {
code: '',
mobile: '',
company: '',
email: ''
};
this.currentPage = 1;
this.searchEmployees();
},
onPageChange(page) {
this.currentPage = page;
this.searchEmployees();
},
selectItem(item) {
this.selectedItem = item;
this.menu = false;
if (this.returnObject) {
this.$emit('update:modelValue', item);
} else {
this.$emit('update:modelValue', item.id);
}
this.$emit('change', item);
},
clearSelection() {
this.selectedItem = null;
this.searchQuery = '';
if (this.returnObject) {
this.$emit('update:modelValue', null);
} else {
this.$emit('update:modelValue', null);
}
this.$emit('change', null);
},
handleEnter() {
if (this.filteredItems.length === 1) {
this.selectItem(this.filteredItems[0]);
}
},
getInitials(name) {
if (!name) return '?';
return name.split(' ').map(word => word.charAt(0)).join('').toUpperCase().substring(0, 2);
}
}
};
</script>
<style scoped>
.search-card {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.list-container {
max-height: 400px;
overflow-y: auto;
}
.search-result-item {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid rgba(var(--v-theme-outline), 0.1);
background-color: rgb(var(--v-theme-surface));
margin: 4px 0;
}
.search-result-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
border-color: rgba(var(--v-theme-primary), 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
transform: translateY(-1px);
}
.selected-item {
background-color: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.employee-chip {
min-width: 80px;
justify-content: center;
font-weight: 500;
letter-spacing: 0.3px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
:deep(.v-list-item__content) {
padding: 12px;
}
:deep(.v-list-item) {
min-height: 80px;
}
:deep(.v-list) {
padding: 8px;
}
:deep(.v-card) {
border-radius: 16px;
overflow: hidden;
}
:deep(.v-card-text) {
padding: 16px;
}
:deep(.text-primary-dark) {
color: rgb(var(--v-theme-primary-dark)) !important;
}
:deep(.v-expansion-panel) {
border-radius: 8px;
}
:deep(.v-expansion-panel-title) {
min-height: 48px;
}
</style>

View file

@ -0,0 +1,304 @@
<template>
<div>
<v-menu v-model="menu" :close-on-content-click="false">
<template v-slot:activator="{ props }">
<v-text-field
v-bind="props"
v-model="displayValue"
variant="outlined"
:error-messages="errorMessages"
:rules="combinedRules"
:label="label"
class="my-0"
prepend-inner-icon="mdi-account-tie"
clearable
@click:clear="clearSelection"
:loading="loading"
@keydown.enter="handleEnter"
hide-details
density="comfortable"
style="font-size: 0.7rem;"
>
<template v-slot:append-inner>
<v-icon>{{ menu ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="300" max-width="400" class="search-card">
<v-card-text class="pa-2">
<template v-if="!loading">
<v-list density="compact" class="list-container">
<template v-if="filteredItems.length > 0">
<v-list-item
v-for="item in filteredItems"
:key="item.id"
@click="selectItem(item)"
class="mb-2 search-result-item"
:class="{ 'selected-item': selectedItem?.id === item.id }"
>
<div class="d-flex flex-column w-100">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon size="small" color="primary" class="mr-1">mdi-account-tie</v-icon>
<span class="text-caption text-primary-dark">{{ item.mobile || 'بدون موبایل' }}</span>
</div>
<span class="text-caption text-grey-darken-1">{{ item.code }}</span>
</div>
<div class="d-flex align-center justify-space-between">
<span class="text-body-2 font-weight-medium text-primary-dark">{{ item.name }}</span>
<v-chip
size="small"
color="success"
class="employee-chip"
>
کارمند
</v-chip>
</div>
</div>
</v-list-item>
</template>
<template v-else>
<v-list-item>
<v-list-item-title class="text-center text-grey">
نتیجهای یافت نشد
</v-list-item-title>
</v-list-item>
</template>
</v-list>
</template>
<v-progress-circular
v-else
indeterminate
color="primary"
class="d-flex mx-auto my-4"
></v-progress-circular>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'HemployeeSearch',
props: {
modelValue: {
type: [Object, Number],
default: null
},
label: {
type: String,
default: 'پرسنل'
},
returnObject: {
type: Boolean,
default: false
},
rules: {
type: Array,
default: () => []
}
},
data() {
return {
selectedItem: null,
items: [],
loading: false,
menu: false,
searchQuery: '',
totalItems: 0,
currentPage: 1,
itemsPerPage: 10,
searchTimeout: null,
errorMessages: []
};
},
computed: {
filteredItems() {
return Array.isArray(this.items) ? this.items : [];
},
displayValue: {
get() {
if (this.menu) {
return this.searchQuery;
}
return this.selectedItem ? this.selectedItem.name : this.searchQuery;
},
set(value) {
this.searchQuery = value;
if (!value) {
this.clearSelection();
}
}
},
combinedRules() {
return [
v => !!v || 'انتخاب پرسنل الزامی است',
...this.rules
]
}
},
watch: {
modelValue: {
handler(newVal) {
if (this.returnObject) {
this.selectedItem = newVal;
} else {
this.selectedItem = this.items.find(item => item.id === newVal);
}
},
immediate: true
},
searchQuery: {
handler(newVal) {
this.currentPage = 1;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
this.fetchData();
}, 500);
}
}
},
mounted() {
this.fetchData();
},
methods: {
async fetchData() {
this.loading = true;
try {
const response = await axios.post('/api/hrm/attendance/employees/search', {
search: this.searchQuery,
page: this.currentPage,
limit: this.itemsPerPage
});
if (response.data && response.data.items) {
this.items = response.data.items;
this.totalItems = response.data.total || response.data.items.length;
} else {
this.items = [];
this.totalItems = 0;
}
if (this.modelValue) {
if (this.returnObject) {
this.selectedItem = this.modelValue;
} else {
this.selectedItem = this.items.find(item => item.id === this.modelValue);
}
}
} catch (error) {
console.error('خطا در بارگذاری پرسنل:', error);
this.items = [];
this.totalItems = 0;
} finally {
this.loading = false;
}
},
selectItem(item) {
this.selectedItem = item;
this.menu = false;
if (this.returnObject) {
this.$emit('update:modelValue', item);
} else {
this.$emit('update:modelValue', item.id);
}
this.$emit('change', item);
},
clearSelection() {
this.selectedItem = null;
this.searchQuery = '';
if (this.returnObject) {
this.$emit('update:modelValue', null);
} else {
this.$emit('update:modelValue', null);
}
this.$emit('change', null);
},
handleEnter() {
if (this.filteredItems.length === 1) {
this.selectItem(this.filteredItems[0]);
}
}
}
};
</script>
<style scoped>
.search-card {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.list-container {
max-height: 300px;
overflow-y: auto;
}
.search-result-item {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid rgba(var(--v-theme-outline), 0.1);
background-color: rgb(var(--v-theme-surface));
margin: 4px 0;
}
.search-result-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
border-color: rgba(var(--v-theme-primary), 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
transform: translateY(-1px);
}
.selected-item {
background-color: rgba(var(--v-theme-primary), 0.08);
border-color: rgba(var(--v-theme-primary), 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.employee-chip {
min-width: 80px;
justify-content: center;
font-weight: 500;
letter-spacing: 0.3px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
:deep(.v-list-item__content) {
padding: 12px;
}
:deep(.v-list-item) {
min-height: 72px;
}
:deep(.v-list) {
padding: 8px;
}
:deep(.v-card) {
border-radius: 16px;
overflow: hidden;
}
:deep(.v-card-text) {
padding: 16px;
}
:deep(.text-primary-dark) {
color: rgb(var(--v-theme-primary-dark)) !important;
}
</style>

View file

@ -8,6 +8,10 @@ const en_lang = {
logout_loading: "you logged out ..." logout_loading: "you logged out ..."
}, },
dialog:{ dialog:{
back: "Back",
add_new: "Add New",
delete: "Delete",
manage_columns: "Manage Columns",
ok: "Ok", ok: "Ok",
cancel: "Cancel", cancel: "Cancel",
save: "Save", save: "Save",
@ -89,6 +93,12 @@ const en_lang = {
inquiry: "Inquiries", inquiry: "Inquiries",
hrm: 'HR & Payroll', hrm: 'HR & Payroll',
hrm_docs: 'Payroll Document', hrm_docs: 'Payroll Document',
hrm_attendance: 'Personnel Attendance',
hrm_attendance_list: 'Attendance List',
hrm_attendance_add: 'Add Attendance',
hrm_attendance_edit: 'Edit Attendance',
hrm_attendance_view: 'View Attendance',
hrm_attendance_reports: 'Attendance Reports',
warranty_system: 'Warranty System', warranty_system: 'Warranty System',
warranty_serials: 'Warranty Serials', warranty_serials: 'Warranty Serials',
business_switcher: 'Switch Business', business_switcher: 'Switch Business',

View file

@ -40,6 +40,7 @@ const fa_lang = {
credit_balance: "تراز بستانکار", credit_balance: "تراز بستانکار",
operations: "عملیات", operations: "عملیات",
rows_per_page: "تعداد سطر در هر صفحه", rows_per_page: "تعداد سطر در هر صفحه",
"تعداد سطر": "تعداد سطر",
no_data: "اطلاعاتی برای نمایش وجود ندارد", no_data: "اطلاعاتی برای نمایش وجود ندارد",
of: "از", of: "از",
date: "تاریخ", date: "تاریخ",
@ -308,6 +309,10 @@ const fa_lang = {
fetch_data_error: "خطا در گرفتن داده از {url}" fetch_data_error: "خطا در گرفتن داده از {url}"
}, },
dialog: { dialog: {
back: "بازگشت",
add_new: "افزودن جدید",
delete: "حذف",
manage_columns: "مدیریت ستون‌ها",
person_with_det_report: 'گزارش تفضیلی اشخاص', person_with_det_report: 'گزارش تفضیلی اشخاص',
change_password_label: 'تغییر کلمه عبور', change_password_label: 'تغییر کلمه عبور',
download: 'دانلود', download: 'دانلود',
@ -406,7 +411,6 @@ const fa_lang = {
filter: "فیلتر", filter: "فیلتر",
filters: "فیلتر‌ها", filters: "فیلتر‌ها",
commodity_not_found: "کالا یافت نشد", commodity_not_found: "کالا یافت نشد",
add_new: "افزودن مورد جدید",
fiscal_year: "سال مالی", fiscal_year: "سال مالی",
currency: "واحد پولی", currency: "واحد پولی",
notifications: "اعلانات", notifications: "اعلانات",
@ -431,7 +435,6 @@ const fa_lang = {
payment: "ثبت پرداخت", payment: "ثبت پرداخت",
exit: "خروج از حساب کاربری", exit: "خروج از حساب کاربری",
complete_all: "موارد الزامی را تکمیل کنید", complete_all: "موارد الزامی را تکمیل کنید",
back: "صفحه قبل",
search: "جست و جو ...", search: "جست و جو ...",
general: "عمومی", general: "عمومی",
prices: "قیمت‌ها", prices: "قیمت‌ها",
@ -488,7 +491,6 @@ const fa_lang = {
system: "سیستم", system: "سیستم",
database: "بانک اطلاعاتی", database: "بانک اطلاعاتی",
edit: "ویرایش", edit: "ویرایش",
delete: "حذف",
each: "هر", each: "هر",
logout: "خروج", logout: "خروج",
import_excel: "درون ریزی از اکسل", import_excel: "درون ریزی از اکسل",
@ -519,12 +521,18 @@ const fa_lang = {
error_operation: "در انجام عملیات خطایی به وجود آمد.در صورت تکرار خطا با پشتیبان نرم افزار تماس بگیرید.", error_operation: "در انجام عملیات خطایی به وجود آمد.در صورت تکرار خطا با پشتیبان نرم افزار تماس بگیرید.",
"success": "موفقیت", "success": "موفقیت",
"error_unknown": "خطای ناشناخته‌ای رخ داد", "error_unknown": "خطای ناشناخته‌ای رخ داد",
"manage_columns": "مدیریت ستون‌ها",
customize_columns: "شخصی‌سازی ستون‌ها", customize_columns: "شخصی‌سازی ستون‌ها",
close_dialog: "بستن", close_dialog: "بستن",
presell_info: "اطلاعات پیش فاکتور", presell_info: "اطلاعات پیش فاکتور",
financial_info: "اطلاعات مالی", financial_info: "اطلاعات مالی",
invoice_items: "اقلام فاکتور", invoice_items: "اقلام فاکتور",
select_file: "انتخاب فایل",
select_file_first: "لطفاً فایل را انتخاب کنید",
file_format: "فرمت فایل",
clear: "پاک کردن",
yes: "بله",
no: "خیر",
no_items_selected: "هیچ موردی انتخاب نشده است",
hrm: { hrm: {
title: "سند حقوق", title: "سند حقوق",
date: "تاریخ", date: "تاریخ",
@ -549,6 +557,72 @@ const fa_lang = {
date: "تاریخ الزامی است", date: "تاریخ الزامی است",
description: "توضیحات الزامی است", description: "توضیحات الزامی است",
person: "انتخاب شخص الزامی است" person: "انتخاب شخص الزامی است"
},
attendance: {
title: "تردد پرسنل",
list: "لیست ترددها",
add: "افزودن تردد",
edit: "ویرایش تردد",
view: "مشاهده تردد",
reports: "گزارشات تردد",
employee: "پرسنل",
date: "تاریخ",
time: "زمان",
type: "نوع",
entry: "ورود",
exit: "خروج",
total_hours: "ساعات کل کار",
overtime_hours: "ساعات اضافه‌کاری",
description: "توضیحات",
status: "وضعیت",
actions: "عملیات",
created_at: "تاریخ ثبت",
person_name: "نام پرسنل",
person_code: "کد پرسنل",
work_hours: "ساعات کار",
overtime: "اضافه‌کاری",
notes: "یادداشت",
import_excel: "واردات از اکسل",
export_excel: "خروجی اکسل",
generate_report: "تولید گزارش",
daily_report: "گزارش روزانه",
monthly_report: "گزارش ماهانه",
overtime_report: "گزارش اضافه‌کاری",
absence_report: "گزارش تاخیر و غیبت",
from_date: "از تاریخ",
to_date: "تا تاریخ",
filter_by_person: "فیلتر بر اساس پرسنل",
all_persons: "همه پرسنل",
report_filters: "فیلترهای گزارش",
report_type: "نوع گزارش",
summary: {
total_days: "کل روزهای کاری",
total_hours: "کل ساعات کار",
total_overtime: "کل اضافه‌کاری",
average_hours: "میانگین ساعات روزانه"
},
statuses: {
full_attendance: "حضور کامل",
part_time: "نیمه وقت",
late: "تاخیر",
absent: "غیبت"
},
messages: {
no_data: "هیچ داده‌ای برای نمایش وجود ندارد",
select_date_range: "لطفاً بازه زمانی را مشخص کنید",
import_success: "رکوردها با موفقیت وارد شدند",
export_success: "فایل با موفقیت دانلود شد",
delete_confirm: "آیا برای حذف موارد انتخاب شده مطمئن هستید؟",
delete_success: "موارد انتخاب شده با موفقیت حذف شدند",
save_success: "تردد با موفقیت ذخیره شد",
edit_success: "تردد با موفقیت ویرایش شد",
load_error: "خطا در بارگذاری اطلاعات",
save_error: "خطا در ذخیره اطلاعات",
delete_error: "خطا در حذف اطلاعات",
import_error: "خطا در واردات فایل",
export_error: "خطا در خروجی فایل",
report_error: "خطا در تولید گزارش"
}
} }
}, },
buysell_report: { buysell_report: {

View file

@ -1,23 +1,46 @@
<template> <template>
<div> <div>
<v-toolbar color="primary" dark> <v-toolbar color="toolbar" title="تردد پرسنل">
<v-toolbar-title> <template v-slot:prepend>
<v-icon start icon="mdi-clock-check"></v-icon> <v-tooltip :text="$t('dialog.back')" location="bottom">
مدیریت تردد پرسنل <template v-slot:activator="{ props }">
</v-toolbar-title> <v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text"
icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn v-if="permissions.plugHrmAttendance" prepend-icon="mdi-plus" @click="$router.push('/acc/hrm/attendance/mod/0')"> <v-tooltip :text="$t('dialog.add_new')" location="bottom">
افزودن تردد <template v-slot:activator="{ props }">
</v-btn> <v-btn v-bind="props" icon="mdi-plus" color="primary" to="/acc/hrm/attendance/mod/0" v-if="permissions.plugHrmAttendance"></v-btn>
<v-btn prepend-icon="mdi-upload" @click="showImportDialog = true"> </template>
واردات از اکسل </v-tooltip>
</v-btn> <v-tooltip :text="$t('dialog.delete')" location="bottom">
<v-btn prepend-icon="mdi-download" @click="exportData"> <template v-slot:activator="{ props }">
خروجی اکسل <v-btn v-bind="props" icon="mdi-delete" color="danger" @click="deleteItems()" :disabled="itemsSelected.length === 0"></v-btn>
</v-btn> </template>
<v-btn prepend-icon="mdi-chart-line" @click="$router.push('/acc/hrm/attendance/reports')"> </v-tooltip>
گزارشات <v-tooltip text="واردات از اکسل" location="bottom">
</v-btn> <template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-upload" color="success" @click="showImportDialog = true"></v-btn>
</template>
</v-tooltip>
<v-tooltip text="خروجی اکسل" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-download" color="info" @click="exportData"></v-btn>
</template>
</v-tooltip>
<v-tooltip text="گزارشات تردد" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-chart-line" color="warning" @click="$router.push('/acc/hrm/attendance/reports')"></v-btn>
</template>
</v-tooltip>
<!-- دکمه تنظیمات ستونها -->
<v-tooltip :text="$t('dialog.column_settings')" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-table-cog" color="primary" @click="showColumnDialog = true" />
</template>
</v-tooltip>
</v-toolbar> </v-toolbar>
<v-container fluid class="pa-4"> <v-container fluid class="pa-4">
@ -32,14 +55,11 @@
<Hdatepicker v-model="filters.toDate" label="تا تاریخ" /> <Hdatepicker v-model="filters.toDate" label="تا تاریخ" />
</v-col> </v-col>
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<v-select <HemployeeAdvancedSearch
v-model="filters.personId" v-model="filters.personId"
:items="employees"
item-title="name"
item-value="id"
label="پرسنل" label="پرسنل"
clearable return-object
prepend-inner-icon="mdi-account" @change="onEmployeeChange"
/> />
</v-col> </v-col>
<v-col cols="12" md="3" class="d-flex align-center"> <v-col cols="12" md="3" class="d-flex align-center">
@ -59,7 +79,7 @@
<!-- جدول --> <!-- جدول -->
<v-card> <v-card>
<v-data-table <v-data-table
:headers="headers" :headers="visibleHeaders"
:items="attendances" :items="attendances"
:loading="loading" :loading="loading"
:items-per-page="filters.limit" :items-per-page="filters.limit"
@ -67,6 +87,8 @@
:server-items-length="total" :server-items-length="total"
@update:options="handleTableUpdate" @update:options="handleTableUpdate"
class="elevation-1" class="elevation-1"
v-model="itemsSelected"
show-select
> >
<template v-slot:item.date="{ item }"> <template v-slot:item.date="{ item }">
<span>{{ formatDate(item.date) }}</span> <span>{{ formatDate(item.date) }}</span>
@ -120,10 +142,10 @@
/> />
<v-alert type="info" variant="tonal" class="mt-2"> <v-alert type="info" variant="tonal" class="mt-2">
<strong>فرمت فایل:</strong><br> <strong>فرمت فایل:</strong><br>
ستون A: کد پرسنل<br> کد پرسنل: A<br>
ستون B: تاریخ (YYYY/MM/DD)<br> تاریخ: B (YYYY/MM/DD)<br>
ستون C: زمان (HH:MM)<br> زمان: C (HH:MM)<br>
ستون D: نوع (ورود/خروج) نوع: D (ورود/خروج)
</v-alert> </v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@ -136,6 +158,27 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- دیالوگ تنظیمات ستونها -->
<v-dialog v-model="showColumnDialog" max-width="500px">
<v-card>
<v-toolbar dark>
<v-toolbar-title>{{ $t('dialog.manage_columns') }}</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="showColumnDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-toolbar>
<v-card-text>
<v-row>
<v-col v-for="header in headers" :key="header.value" cols="12" sm="6" class="my-0 py-0">
<v-checkbox v-model="header.visible" :label="getHeaderTitle(header.value)" @update:model-value="updateColumnVisibility"
hide-details="auto" />
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-dialog>
<!-- اسنکبار --> <!-- اسنکبار -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000"> <v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }} {{ snackbar.text }}
@ -145,12 +188,15 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import Swal from 'sweetalert2';
import Hdatepicker from '@/components/forms/Hdatepicker.vue'; import Hdatepicker from '@/components/forms/Hdatepicker.vue';
import HemployeeAdvancedSearch from '@/components/forms/HemployeeAdvancedSearch.vue';
export default { export default {
name: 'AttendanceList', name: 'AttendanceList',
components: { components: {
Hdatepicker Hdatepicker,
HemployeeAdvancedSearch
}, },
data() { data() {
return { return {
@ -159,9 +205,10 @@ export default {
showImportDialog: false, showImportDialog: false,
importFile: null, importFile: null,
attendances: [], attendances: [],
employees: [],
total: 0, total: 0,
permissions: {}, permissions: {},
itemsSelected: [],
showColumnDialog: false,
filters: { filters: {
page: 1, page: 1,
limit: 20, limit: 20,
@ -170,13 +217,13 @@ export default {
personId: null personId: null
}, },
headers: [ headers: [
{ title: 'تاریخ', value: 'date', sortable: true }, { title: 'تاریخ', value: 'date', sortable: true, visible: true },
{ title: 'نام پرسنل', value: 'personName', sortable: true }, { title: 'نام پرسنل', value: 'personName', sortable: true, visible: true },
{ title: 'ساعات کل کار', value: 'totalHours', sortable: true }, { title: 'ساعات کل کار', value: 'totalHours', sortable: true, visible: true },
{ title: 'ساعات اضافه‌کاری', value: 'overtimeHours', sortable: true }, { title: 'ساعات اضافه‌کاری', value: 'overtimeHours', sortable: true, visible: true },
{ title: 'توضیحات', value: 'description', sortable: false }, { title: 'توضیحات', value: 'description', sortable: false, visible: true },
{ title: 'تاریخ ثبت', value: 'createdAt', sortable: true }, { title: 'تاریخ ثبت', value: 'createdAt', sortable: true, visible: true },
{ title: 'عملیات', value: 'actions', sortable: false, width: '120px' } { title: 'عملیات', value: 'actions', sortable: false, visible: true, width: '120px' }
], ],
snackbar: { snackbar: {
show: false, show: false,
@ -185,10 +232,18 @@ export default {
} }
}; };
}, },
computed: {
visibleHeaders() {
return this.headers.filter(header => header.visible).map(header => ({
...header,
title: this.getHeaderTitle(header.value)
}));
}
},
async mounted() { async mounted() {
await this.loadPermissions(); await this.loadPermissions();
await this.loadEmployees();
await this.loadData(); await this.loadData();
this.loadColumnSettings();
}, },
methods: { methods: {
async loadPermissions() { async loadPermissions() {
@ -200,15 +255,7 @@ export default {
} }
}, },
async loadEmployees() {
try {
const response = await axios.post('/api/hrm/attendance/employees');
this.employees = response.data;
} catch (error) {
console.error('Error loading employees:', error);
this.showSnackbar('خطا در بارگذاری لیست پرسنل', 'error');
}
},
async loadData() { async loadData() {
this.loading = true; this.loading = true;
@ -292,6 +339,65 @@ export default {
} }
}, },
deleteItems() {
if (this.itemsSelected.length === 0) {
this.showSnackbar('هیچ موردی انتخاب نشده است.', 'warning');
return;
}
Swal.fire({
text: this.$t('hrm.attendance.messages.delete_confirm'),
showCancelButton: true,
confirmButtonText: this.$t('dialog.yes'),
cancelButtonText: this.$t('dialog.no'),
icon: 'warning'
}).then((result) => {
if (result.isConfirmed) {
this.loading = true;
const selectedIds = this.itemsSelected;
Promise.all(selectedIds.map(id =>
axios.post('/api/hrm/attendance/delete', { id })
)).then(() => {
this.showSnackbar('موارد انتخاب شده با موفقیت حذف شدند', 'success');
this.itemsSelected = [];
this.loadData();
}).catch((error) => {
console.error('Error deleting items:', error);
this.showSnackbar('خطا در حذف موارد انتخاب شده', 'error');
}).finally(() => {
this.loading = false;
});
}
});
},
deleteAttendance(item) {
Swal.fire({
text: 'آیا برای حذف این تردد مطمئن هستید؟',
showCancelButton: true,
confirmButtonText: 'بله',
cancelButtonText: 'خیر',
icon: 'warning'
}).then((result) => {
if (result.isConfirmed) {
this.loading = true;
axios.post('/api/hrm/attendance/delete', { id: item.id })
.then(() => {
this.showSnackbar('تردد با موفقیت حذف شد', 'success');
this.loadData();
})
.catch((error) => {
console.error('Error deleting attendance:', error);
this.showSnackbar('خطا در حذف تردد', 'error');
})
.finally(() => {
this.loading = false;
});
}
});
},
handleTableUpdate(options) { handleTableUpdate(options) {
this.filters.page = options.page; this.filters.page = options.page;
this.filters.limit = options.itemsPerPage; this.filters.limit = options.itemsPerPage;
@ -327,6 +433,42 @@ export default {
text, text,
color color
}; };
},
updateColumnVisibility() {
localStorage.setItem('hrmAttendanceColumns', JSON.stringify(this.headers));
},
loadColumnSettings() {
const savedColumns = localStorage.getItem('hrmAttendanceColumns');
if (savedColumns) {
const parsedColumns = JSON.parse(savedColumns);
this.headers = this.headers.map(header => ({
...header,
visible: parsedColumns.find(h => h.value === header.value)?.visible ?? true,
}));
}
},
getHeaderTitle(value) {
const titleMap = {
'date': 'تاریخ',
'personName': 'نام پرسنل',
'totalHours': 'ساعات کل کار',
'overtimeHours': 'ساعات اضافه‌کاری',
'description': 'توضیحات',
'createdAt': 'تاریخ ثبت',
'actions': 'عملیات'
};
return titleMap[value] || value;
},
onEmployeeChange(employee) {
if (employee) {
this.filters.personId = employee.id;
} else {
this.filters.personId = null;
}
} }
} }
}; };

View file

@ -1,14 +1,20 @@
<template> <template>
<div> <div>
<v-toolbar color="primary" dark> <v-toolbar color="toolbar" :title="isEdit ? 'ویرایش تردد' : 'افزودن تردد جدید'">
<v-toolbar-title> <template v-slot:prepend>
<v-icon start icon="mdi-clock-check"></v-icon> <v-tooltip :text="$t('dialog.back')" location="bottom">
{{ isEdit ? 'ویرایش تردد' : 'افزودن تردد جدید' }} <template v-slot:activator="{ props }">
</v-toolbar-title> <v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text"
icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn prepend-icon="mdi-arrow-left" @click="$router.push('/acc/hrm/attendance/list')"> <v-tooltip :text="isEdit ? 'ویرایش' : 'ذخیره'" location="bottom">
بازگشت <template v-slot:activator="{ props }">
</v-btn> <v-btn v-bind="props" icon="mdi-content-save" color="primary" @click="save" :loading="saving"></v-btn>
</template>
</v-tooltip>
</v-toolbar> </v-toolbar>
<v-container fluid class="pa-4"> <v-container fluid class="pa-4">
@ -17,15 +23,11 @@
<v-form ref="form" @submit.prevent="save"> <v-form ref="form" @submit.prevent="save">
<v-row> <v-row>
<v-col cols="12" md="4"> <v-col cols="12" md="4">
<v-select <HemployeeAdvancedSearch
v-model="form.personId" v-model="selectedEmployee"
:items="employees"
item-title="name"
item-value="id"
label="پرسنل *" label="پرسنل *"
required return-object
prepend-inner-icon="mdi-account" @change="onEmployeeChange"
:rules="[v => !!v || 'انتخاب پرسنل الزامی است']"
/> />
</v-col> </v-col>
<v-col cols="12" md="4"> <v-col cols="12" md="4">
@ -93,7 +95,7 @@
color="error" color="error"
variant="text" variant="text"
@click="removeItem(index)" @click="removeItem(index)"
:disabled="form.items.length <= 1" :disabled="form.items.length === 1"
/> />
</v-col> </v-col>
</v-row> </v-row>
@ -103,29 +105,13 @@
<!-- خلاصه --> <!-- خلاصه -->
<v-card class="mt-4" variant="outlined"> <v-card class="mt-4" variant="outlined">
<v-card-title class="text-h6"> <v-card-title class="text-h6">
<v-icon start>mdi-calculator</v-icon> <v-icon start>mdi-chart-line</v-icon>
خلاصه ساعات کار خلاصه
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<v-row> <v-row>
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<v-card variant="tonal" color="primary"> <v-card variant="tonal" color="primary">
<v-card-text class="text-center">
<div class="text-h6">{{ formatMinutesToHours(summary.totalHours) }}</div>
<div class="text-caption">ساعات کل کار</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="tonal" color="warning">
<v-card-text class="text-center">
<div class="text-h6">{{ formatMinutesToHours(summary.overtimeHours) }}</div>
<div class="text-caption">ساعات اضافهکاری</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="tonal" color="success">
<v-card-text class="text-center"> <v-card-text class="text-center">
<div class="text-h6">{{ summary.entries }}</div> <div class="text-h6">{{ summary.entries }}</div>
<div class="text-caption">تعداد ورود</div> <div class="text-caption">تعداد ورود</div>
@ -133,35 +119,48 @@
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<v-card variant="tonal" color="info"> <v-card variant="tonal" color="secondary">
<v-card-text class="text-center"> <v-card-text class="text-center">
<div class="text-h6">{{ summary.exits }}</div> <div class="text-h6">{{ summary.exits }}</div>
<div class="text-caption">تعداد خروج</div> <div class="text-caption">تعداد خروج</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="3">
<v-card variant="tonal" color="success">
<v-card-text class="text-center">
<div class="text-h6">{{ formatMinutesToHours(summary.totalHours) }}</div>
<div class="text-caption">ساعات کار</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="tonal" color="warning">
<v-card-text class="text-center">
<div class="text-h6">{{ formatMinutesToHours(summary.overtimeHours) }}</div>
<div class="text-caption">اضافهکاری</div>
</v-card-text>
</v-card>
</v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>
</v-card> </v-card>
<v-row class="mt-4"> <v-row class="mt-4">
<v-col cols="12" class="text-center"> <v-col cols="12" class="d-flex justify-end">
<v-btn <v-btn
type="submit" @click="$router.back()"
color="primary" class="me-2"
size="large"
:loading="saving"
prepend-icon="mdi-content-save"
>
{{ isEdit ? 'ویرایش' : 'ثبت' }}
</v-btn>
<v-btn
class="ms-2"
size="large"
@click="$router.push('/acc/hrm/attendance/list')"
> >
انصراف انصراف
</v-btn> </v-btn>
<v-btn
color="primary"
@click="save"
:loading="saving"
>
{{ isEdit ? 'ویرایش' : 'ذخیره' }}
</v-btn>
</v-col> </v-col>
</v-row> </v-row>
</v-form> </v-form>
@ -179,17 +178,19 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import Hdatepicker from '@/components/forms/Hdatepicker.vue'; import Hdatepicker from '@/components/forms/Hdatepicker.vue';
import HemployeeAdvancedSearch from '@/components/forms/HemployeeAdvancedSearch.vue';
export default { export default {
name: 'AttendanceMod', name: 'AttendanceMod',
components: { components: {
Hdatepicker Hdatepicker,
HemployeeAdvancedSearch
}, },
data() { data() {
return { return {
isEdit: false, isEdit: false,
saving: false, saving: false,
employees: [], selectedEmployee: null,
form: { form: {
personId: null, personId: null,
date: '', date: '',
@ -247,17 +248,16 @@ export default {
} }
}, },
async mounted() { async mounted() {
await this.loadEmployees();
await this.loadData(); await this.loadData();
}, },
methods: { methods: {
async loadEmployees() { onEmployeeChange(employee) {
try { if (employee) {
const response = await axios.post('/api/hrm/attendance/employees'); this.selectedEmployee = employee;
this.employees = response.data; this.form.personId = employee.id;
} catch (error) { } else {
console.error('Error loading employees:', error); this.selectedEmployee = null;
this.showSnackbar('خطا در بارگذاری لیست پرسنل', 'error'); this.form.personId = null;
} }
}, },
@ -269,6 +269,18 @@ export default {
const response = await axios.post(`/api/hrm/attendance/get/${id}`); const response = await axios.post(`/api/hrm/attendance/get/${id}`);
const data = response.data; const data = response.data;
// ابتدا پرسنل را پیدا کنیم
const employeeResponse = await axios.post('/api/hrm/attendance/employees/search', {
search: '',
page: 1,
limit: 1000
});
const employee = employeeResponse.data.items.find(emp => emp.id === data.personId);
if (employee) {
this.selectedEmployee = employee;
}
this.form.personId = data.personId; this.form.personId = data.personId;
this.form.date = data.date; this.form.date = data.date;
this.form.description = data.description; this.form.description = data.description;
@ -302,6 +314,12 @@ export default {
const { valid } = await this.$refs.form.validate(); const { valid } = await this.$refs.form.validate();
if (!valid) return; if (!valid) return;
// بررسی validation دستی برای پرسنل
if (!this.selectedEmployee || !this.selectedEmployee.id) {
this.showSnackbar('انتخاب پرسنل الزامی است', 'error');
return;
}
this.saving = true; this.saving = true;
try { try {
const id = this.$route.params.id; const id = this.$route.params.id;
@ -341,13 +359,6 @@ export default {
return hours * 60 + minutes; return hours * 60 + minutes;
}, },
getTimestamp(date, time) {
// تبدیل تاریخ و زمان شمسی به timestamp
const dateTime = `${date} ${time}`;
// اینجا باید از سرویس Jdate استفاده شود
return Math.floor(Date.now() / 1000); // فعلاً timestamp فعلی
},
formatMinutesToHours(minutes) { formatMinutesToHours(minutes) {
if (!minutes) return '0:00'; if (!minutes) return '0:00';
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@ -355,6 +366,13 @@ export default {
return `${hours}:${mins.toString().padStart(2, '0')}`; return `${hours}:${mins.toString().padStart(2, '0')}`;
}, },
getTimestamp(date, time) {
// تبدیل تاریخ و زمان به timestamp
const [year, month, day] = date.split('/').map(Number);
const [hour, minute] = time.split(':').map(Number);
return new Date(year, month - 1, day, hour, minute).getTime();
},
showSnackbar(text, color = 'success') { showSnackbar(text, color = 'success') {
this.snackbar = { this.snackbar = {
show: true, show: true,

View file

@ -1,14 +1,25 @@
<template> <template>
<div> <div>
<v-toolbar color="primary" dark> <v-toolbar color="toolbar" :title="'گزارشات تردد پرسنل'">
<v-toolbar-title> <template v-slot:prepend>
<v-icon start icon="mdi-chart-line"></v-icon> <v-tooltip :text="$t('dialog.back')" location="bottom">
گزارشات تردد پرسنل <template v-slot:activator="{ props }">
</v-toolbar-title> <v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text"
icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn prepend-icon="mdi-arrow-right" @click="$router.back()"> <v-tooltip text="تولید گزارش" location="bottom">
بازگشت <template v-slot:activator="{ props }">
</v-btn> <v-btn v-bind="props" icon="mdi-chart-line" color="primary" @click="generateReport" :loading="loading"></v-btn>
</template>
</v-tooltip>
<v-tooltip text="خروجی اکسل" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-download" color="success" @click="exportReport" :loading="exporting" :disabled="!reportData.length"></v-btn>
</template>
</v-tooltip>
</v-toolbar> </v-toolbar>
<v-container fluid class="pa-4"> <v-container fluid class="pa-4">

View file

@ -1,17 +1,20 @@
<template> <template>
<div> <div>
<v-toolbar color="primary" dark> <v-toolbar color="toolbar" :title="'مشاهده جزئیات تردد'">
<v-toolbar-title> <template v-slot:prepend>
<v-icon start icon="mdi-eye"></v-icon> <v-tooltip :text="$t('dialog.back')" location="bottom">
مشاهده جزئیات تردد <template v-slot:activator="{ props }">
</v-toolbar-title> <v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text"
icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn v-if="permissions.plugHrmAttendance" prepend-icon="mdi-pencil" @click="$router.push(`/acc/hrm/attendance/mod/${attendance.id}`)"> <v-tooltip text="ویرایش" location="bottom" v-if="permissions.plugHrmAttendance">
ویرایش <template v-slot:activator="{ props }">
</v-btn> <v-btn v-bind="props" icon="mdi-pencil" color="primary" @click="$router.push(`/acc/hrm/attendance/mod/${attendance.id}`)"></v-btn>
<v-btn prepend-icon="mdi-arrow-right" @click="$router.back()"> </template>
بازگشت </v-tooltip>
</v-btn>
</v-toolbar> </v-toolbar>
<v-container fluid class="pa-4" v-if="!loading"> <v-container fluid class="pa-4" v-if="!loading">