Compare commits

...

2 commits

20 changed files with 2492 additions and 4 deletions

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250822072930 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE permission DROP plugHrmAttendance
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_hrm_attendance CHANGE total_hours total_hours INT DEFAULT NULL, CHANGE overtime_hours overtime_hours INT DEFAULT NULL, CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_hrm_attendance_item CHANGE created_at created_at DATETIME NOT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE permission ADD plugHrmAttendance TINYINT(1) DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_hrm_attendance CHANGE total_hours total_hours INT NOT NULL, CHANGE overtime_hours overtime_hours INT NOT NULL, CHANGE created_at created_at INT NOT NULL, CHANGE updated_at updated_at INT NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE plug_hrm_attendance_item CHANGE created_at created_at INT NOT NULL
SQL);
}
}

View file

@ -586,6 +586,9 @@ class BusinessController extends AbstractController
'plugImportWorkflow' => true,
'inquiry' => true,
'ai' => true,
'warehouseManager' => true,
'importWorkflow' => true,
'plugHrmAttendance' => true,
'storehelper' => true,
];
} elseif ($perm) {
@ -635,6 +638,9 @@ class BusinessController extends AbstractController
'plugImportWorkflow' => $perm->isImportWorkflow(),
'inquiry' => $perm->isInquiry(),
'ai' => $perm->isAi(),
'warehouseManager' => $perm->isWarehouseManager(),
'importWorkflow' => $perm->isImportWorkflow(),
'plugHrmAttendance' => $perm->isPlugHrmAttendance(),
'storehelper' => $perm->isStorehelper()
];
}
@ -710,6 +716,9 @@ class BusinessController extends AbstractController
$perm->setImportWorkflow($params['plugImportWorkflow'] ?? false);
$perm->setInquiry($params['inquiry']);
$perm->setAi($params['ai']);
$perm->setWarehouseManager($params['warehouseManager'] ?? false);
$perm->setImportWorkflow($params['importWorkflow'] ?? false);
$perm->setPlugHrmAttendance($params['plugHrmAttendance'] ?? false);
$perm->setStorehelper($params['storehelper'] ?? false);
$entityManager->persist($perm);
$entityManager->flush();

View file

@ -621,7 +621,7 @@ class CostController extends AbstractController
// Set approval status based on business settings
$business = $acc['bid'];
if ($business->getTwoStepApproval()) {
if ($business->isRequireTwoStepApproval()) {
// Two-step approval is enabled
$doc->setIsPreview(true);
$doc->setIsApproved(false);

View file

@ -44,7 +44,7 @@ class DirectHesabdariDoc extends AbstractController
// Set approval status based on business settings
$business = $acc['bid'];
if ($business->getTwoStepApproval()) {
if ($business->isRequireTwoStepApproval()) {
// Two-step approval is enabled
$hesabdariDoc->setIsPreview(true);
$hesabdariDoc->setIsApproved(false);

View file

@ -477,7 +477,7 @@ class HesabdariController extends AbstractController
// Set approval status based on business settings
$business = $acc['bid'];
if ($business->getTwoStepApproval()) {
if ($business->isRequireTwoStepApproval()) {
// Two-step approval is enabled
$doc->setIsPreview(true);
$doc->setIsApproved(false);

View file

@ -618,7 +618,7 @@ class IncomeController extends AbstractController
// Set approval status based on business settings
$business = $acc['bid'];
if ($business->getTwoStepApproval()) {
if ($business->isRequireTwoStepApproval()) {
// Two-step approval is enabled
$doc->setIsPreview(true);
$doc->setIsApproved(false);

View file

@ -515,6 +515,15 @@ class PluginController extends AbstractController
'icon' => 'import-workflow.png',
'defaultOn' => null,
],
[
'name' => ' مدیریت منابع انسانی',
'code' => 'hrm',
'timestamp' => '32104000',
'timelabel' => 'یک سال',
'price' => '200000',
'icon' => 'hmr.jpg',
'defaultOn' => null,
],
];
$repo = $entityManager->getRepository(PluginProdect::class);

View file

@ -0,0 +1,705 @@
<?php
namespace App\Controller\Plugins\Hrm;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\PlugHrmAttendance;
use App\Entity\PlugHrmAttendanceItem;
use App\Entity\Person;
use App\Entity\PersonType;
use App\Service\Access;
use App\Service\Log;
use App\Service\Jdate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class AttendanceController extends AbstractController
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
#[Route('/api/hrm/attendance/list', name: 'hrm_attendance_list', methods: ['POST'])]
public function list(Request $request, Access $access, Jdate $jdate): JsonResponse
{
try {
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
// دریافت پارامترهای فیلتر
$page = $params['page'] ?? 1;
$limit = $params['limit'] ?? 20;
$fromDate = $params['fromDate'] ?? null;
$toDate = $params['toDate'] ?? null;
$personId = $params['personId'] ?? null;
// ایجاد کوئری
$qb = $this->entityManager->createQueryBuilder();
$qb->select('a')
->from(PlugHrmAttendance::class, 'a')
->leftJoin('a.person', 'p')
->addSelect('p')
->where('a.business = :bid')
->setParameter('bid', $acc['bid']);
// اعمال فیلترها
if ($fromDate) {
$qb->andWhere('a.date >= :fromDate')
->setParameter('fromDate', $fromDate);
}
if ($toDate) {
$qb->andWhere('a.date <= :toDate')
->setParameter('toDate', $toDate);
}
if ($personId) {
$qb->andWhere('a.person = :personId')
->setParameter('personId', $personId);
}
$qb->orderBy('a.date', 'DESC')
->addOrderBy('p.nikename', 'ASC');
// محاسبه تعداد کل
$countQb = clone $qb;
$totalCount = $countQb->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
// اعمال صفحه‌بندی
$qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$attendances = $qb->getQuery()->getResult();
// تبدیل به آرایه
$result = [];
foreach ($attendances as $attendance) {
$result[] = [
'id' => $attendance->getId(),
'date' => $attendance->getDate(),
'personId' => $attendance->getPerson()->getId(),
'personName' => $attendance->getPerson()->getNikename(),
'totalHours' => $attendance->getTotalHours(),
'overtimeHours' => $attendance->getOvertimeHours(),
'description' => $attendance->getDescription(),
'createdAt' => $jdate->jdate('Y/n/d H:i', $attendance->getCreatedAt()->getTimestamp()),
];
}
return $this->json([
'data' => $result,
'total' => $totalCount,
'page' => $page,
'limit' => $limit,
'pages' => ceil($totalCount / $limit)
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/mod/{id}', name: 'hrm_attendance_mod', methods: ['POST'])]
public function mod(Request $request, Access $access, Log $log, Jdate $jdate, $id = 0): JsonResponse
{
try {
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
if ($id == 0) {
// ایجاد تردد جدید
$attendance = new PlugHrmAttendance();
$attendance->setBusiness($acc['bid']);
} else {
// ویرایش تردد موجود
$attendance = $this->entityManager->getRepository(PlugHrmAttendance::class)->find($id);
if (!$attendance || $attendance->getBusiness()->getId() != $acc['bid']->getId()) {
throw $this->createNotFoundException('تردد یافت نشد.');
}
}
// بررسی وجود پرسنل
$person = $this->entityManager->getRepository(Person::class)->find($params['personId']);
if (!$person || $person->getBid()->getId() != $acc['bid']->getId()) {
throw $this->createNotFoundException('پرسنل یافت نشد.');
}
$attendance->setPerson($person);
$attendance->setDate($params['date']);
$attendance->setDescription($params['description'] ?? '');
$attendance->setUpdatedAt(new \DateTime());
// محاسبه ساعات کار
$totalHours = 0;
$overtimeHours = 0;
if (isset($params['items']) && is_array($params['items'])) {
// حذف آیتم‌های قبلی
foreach ($attendance->getItems() as $item) {
$this->entityManager->remove($item);
}
// افزودن آیتم‌های جدید
foreach ($params['items'] as $itemData) {
$item = new PlugHrmAttendanceItem();
$item->setAttendance($attendance);
$item->setType($itemData['type']); // ورود یا خروج
$item->setTime($itemData['time']); // HH:MM
$item->setTimestamp($itemData['timestamp']);
$this->entityManager->persist($item);
}
// محاسبه ساعات کار
$workHours = $this->calculateWorkHours($params['items']);
$totalHours = $workHours['total'];
$overtimeHours = $workHours['overtime'];
}
$attendance->setTotalHours($totalHours);
$attendance->setOvertimeHours($overtimeHours);
$this->entityManager->persist($attendance);
$this->entityManager->flush();
$log->insert(
'تردد پرسنل',
'تردد پرسنل ' . $person->getNikename() . ' برای تاریخ ' . $params['date'] . ' ثبت شد.',
$this->getUser(),
$acc['bid']
);
return $this->json(['result' => 1, 'id' => $attendance->getId()]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/get/{id}', name: 'hrm_attendance_get', methods: ['POST'])]
public function get(Request $request, Access $access, Jdate $jdate, $id): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$attendance = $this->entityManager->getRepository(PlugHrmAttendance::class)->find($id);
if (!$attendance || $attendance->getBusiness()->getId() != $acc['bid']->getId()) {
throw $this->createNotFoundException('تردد یافت نشد.');
}
$items = [];
foreach ($attendance->getItems() as $item) {
$items[] = [
'id' => $item->getId(),
'type' => $item->getType(),
'time' => $item->getTime(),
'timestamp' => $item->getTimestamp(),
];
}
$result = [
'id' => $attendance->getId(),
'personId' => $attendance->getPerson()->getId(),
'personName' => $attendance->getPerson()->getNikename(),
'date' => $attendance->getDate(),
'totalHours' => $attendance->getTotalHours(),
'overtimeHours' => $attendance->getOvertimeHours(),
'description' => $attendance->getDescription(),
'items' => $items,
];
return $this->json($result);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/delete/{id}', name: 'hrm_attendance_delete', methods: ['POST'])]
public function delete(Request $request, Access $access, Log $log, $id): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$attendance = $this->entityManager->getRepository(PlugHrmAttendance::class)->find($id);
if (!$attendance || $attendance->getBusiness()->getId() != $acc['bid']->getId()) {
throw $this->createNotFoundException('تردد یافت نشد.');
}
$personName = $attendance->getPerson()->getNikename();
$date = $attendance->getDate();
$this->entityManager->remove($attendance);
$this->entityManager->flush();
$log->insert(
'تردد پرسنل',
'تردد پرسنل ' . $personName . ' برای تاریخ ' . $date . ' حذف شد.',
$this->getUser(),
$acc['bid']
);
return $this->json(['result' => 1]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/employees', name: 'hrm_attendance_employees', methods: ['POST'])]
public function getEmployees(Request $request, Access $access): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
// دریافت نوع پرسنل "کارمند" یا "employee"
$employeeType = $this->entityManager->getRepository(PersonType::class)->findOneBy(['code' => 'employee']);
if (!$employeeType) {
// اگر نوع "employee" وجود نداشت، نوع "کارمند" را جستجو کن
$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);
}
$qb->orderBy('p.nikename', 'ASC');
$employees = $qb->getQuery()->getResult();
$result = [];
foreach ($employees as $employee) {
$result[] = [
'id' => $employee->getId(),
'name' => $employee->getNikename(),
'code' => $employee->getCode(),
];
}
return $this->json($result);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/import', name: 'hrm_attendance_import', methods: ['POST'])]
public function import(Request $request, Access $access, Log $log, Jdate $jdate): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$file = $request->files->get('file');
if (!$file) {
throw new \Exception('فایل انتخاب نشده است.');
}
$spreadsheet = IOFactory::load($file->getPathname());
$worksheet = $spreadsheet->getActiveSheet();
$rows = $worksheet->toArray();
// حذف سطر هدر
array_shift($rows);
$importedCount = 0;
$errors = [];
foreach ($rows as $index => $row) {
try {
if (empty($row[0]) || empty($row[1]) || empty($row[2])) {
continue; // رد کردن سطرهای خالی
}
$personCode = $row[0];
$date = $row[1];
$time = $row[2];
$type = $row[3] ?? 'ورود'; // ورود یا خروج
// پیدا کردن پرسنل بر اساس کد
$person = $this->entityManager->getRepository(Person::class)->findOneBy([
'code' => $personCode,
'bid' => $acc['bid']
]);
if (!$person) {
$errors[] = "سطر " . ($index + 2) . ": پرسنل با کد $personCode یافت نشد.";
continue;
}
// بررسی وجود تردد برای این تاریخ
$attendance = $this->entityManager->getRepository(PlugHrmAttendance::class)
->findByBusinessAndPersonAndDate($acc['bid'], $person, $date);
if (!$attendance) {
$attendance = new PlugHrmAttendance();
$attendance->setBusiness($acc['bid']);
$attendance->setPerson($person);
$attendance->setDate($date);
$attendance->setTotalHours(0);
$attendance->setOvertimeHours(0);
}
// افزودن آیتم تردد
$item = new PlugHrmAttendanceItem();
$item->setAttendance($attendance);
$item->setType($type);
$item->setTime($time);
$item->setTimestamp($jdate->jallaliToUnixTime($date . ' ' . $time));
$this->entityManager->persist($item);
$this->entityManager->persist($attendance);
$importedCount++;
} catch (\Exception $e) {
$errors[] = "سطر " . ($index + 2) . ": " . $e->getMessage();
}
}
$this->entityManager->flush();
$log->insert(
'تردد پرسنل',
"$importedCount رکورد تردد از فایل اکسل وارد شد.",
$this->getUser(),
$acc['bid']
);
return $this->json([
'result' => 1,
'importedCount' => $importedCount,
'errors' => $errors
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/export', name: 'hrm_attendance_export', methods: ['POST'])]
public function export(Request $request, Access $access, Jdate $jdate): JsonResponse
{
try {
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$fromDate = $params['fromDate'] ?? null;
$toDate = $params['toDate'] ?? null;
$personId = $params['personId'] ?? null;
$attendances = $this->entityManager->getRepository(PlugHrmAttendance::class)
->findByBusinessAndDateRange($acc['bid'], $fromDate, $toDate, $personId);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// تنظیم هدر
$sheet->setCellValue('A1', 'کد پرسنل');
$sheet->setCellValue('B1', 'نام پرسنل');
$sheet->setCellValue('C1', 'تاریخ');
$sheet->setCellValue('D1', 'ساعات کل کار');
$sheet->setCellValue('E1', 'ساعات اضافه‌کاری');
$sheet->setCellValue('F1', 'توضیحات');
$row = 2;
foreach ($attendances as $attendance) {
$sheet->setCellValue('A' . $row, $attendance->getPerson()->getCode());
$sheet->setCellValue('B' . $row, $attendance->getPerson()->getNikename());
$sheet->setCellValue('C' . $row, $attendance->getDate());
$sheet->setCellValue('D' . $row, $this->formatMinutesToHours($attendance->getTotalHours()));
$sheet->setCellValue('E' . $row, $this->formatMinutesToHours($attendance->getOvertimeHours()));
$sheet->setCellValue('F' . $row, $attendance->getDescription());
$row++;
}
$writer = new Xlsx($spreadsheet);
$filename = 'attendance_export_' . date('Y-m-d_H-i-s') . '.xlsx';
$filepath = sys_get_temp_dir() . '/' . $filename;
$writer->save($filepath);
return $this->json([
'result' => 1,
'filename' => $filename,
'downloadUrl' => '/api/hrm/attendance/download/' . $filename
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/download/{filename}', name: 'hrm_attendance_download', methods: ['GET'])]
public function download(Request $request, Access $access, $filename): JsonResponse
{
try {
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$filepath = sys_get_temp_dir() . '/' . $filename;
if (!file_exists($filepath)) {
throw $this->createNotFoundException('فایل یافت نشد.');
}
$response = new JsonResponse();
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
$response->setContent(file_get_contents($filepath));
unlink($filepath); // حذف فایل موقت
return $response;
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/reports', name: 'hrm_attendance_reports', methods: ['POST'])]
public function reports(Request $request, Access $access, Jdate $jdate): JsonResponse
{
try {
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$type = $params['type'] ?? 'daily';
$fromDate = $params['fromDate'] ?? null;
$toDate = $params['toDate'] ?? null;
$personId = $params['personId'] ?? null;
$attendances = $this->entityManager->getRepository(PlugHrmAttendance::class)
->findByBusinessAndDateRange($acc['bid'], $fromDate, $toDate, $personId);
$reportData = [];
$summary = [
'totalDays' => 0,
'totalHours' => 0,
'totalOvertime' => 0,
'averageHours' => 0
];
foreach ($attendances as $attendance) {
$reportData[] = [
'id' => $attendance->getId(),
'date' => $attendance->getDate(),
'personName' => $attendance->getPerson()->getNikename(),
'personCode' => $attendance->getPerson()->getCode(),
'totalHours' => $attendance->getTotalHours(),
'overtimeHours' => $attendance->getOvertimeHours(),
'description' => $attendance->getDescription(),
'status' => $this->getAttendanceStatus($attendance)
];
$summary['totalDays']++;
$summary['totalHours'] += $attendance->getTotalHours();
$summary['totalOvertime'] += $attendance->getOvertimeHours();
}
if ($summary['totalDays'] > 0) {
$summary['averageHours'] = round($summary['totalHours'] / $summary['totalDays']);
}
return $this->json([
'result' => 1,
'data' => $reportData,
'summary' => $summary
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
#[Route('/api/hrm/attendance/export-report', name: 'hrm_attendance_export_report', methods: ['POST'])]
public function exportReport(Request $request, Access $access, Jdate $jdate): JsonResponse
{
try {
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
$acc = $access->hasRole('plugHrmAttendance');
if (!$acc) {
throw $this->createAccessDeniedException('شما دسترسی لازم را ندارید.');
}
$type = $params['type'] ?? 'daily';
$fromDate = $params['fromDate'] ?? null;
$toDate = $params['toDate'] ?? null;
$personId = $params['personId'] ?? null;
$attendances = $this->entityManager->getRepository(PlugHrmAttendance::class)
->findByBusinessAndDateRange($acc['bid'], $fromDate, $toDate, $personId);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// تنظیم هدر بر اساس نوع گزارش
$headers = ['تاریخ', 'نام پرسنل', 'کد پرسنل', 'ساعات کل کار', 'ساعات اضافه‌کاری'];
if ($type === 'absence') {
$headers[] = 'وضعیت';
}
$headers[] = 'توضیحات';
foreach ($headers as $index => $header) {
$sheet->setCellValue(chr(65 + $index) . '1', $header);
}
$row = 2;
foreach ($attendances as $attendance) {
$sheet->setCellValue('A' . $row, $attendance->getDate());
$sheet->setCellValue('B' . $row, $attendance->getPerson()->getNikename());
$sheet->setCellValue('C' . $row, $attendance->getPerson()->getCode());
$sheet->setCellValue('D' . $row, $this->formatMinutesToHours($attendance->getTotalHours()));
$sheet->setCellValue('E' . $row, $this->formatMinutesToHours($attendance->getOvertimeHours()));
if ($type === 'absence') {
$sheet->setCellValue('F' . $row, $this->getAttendanceStatus($attendance));
$sheet->setCellValue('G' . $row, $attendance->getDescription());
} else {
$sheet->setCellValue('F' . $row, $attendance->getDescription());
}
$row++;
}
$writer = new Xlsx($spreadsheet);
$filename = 'attendance_report_' . $type . '_' . date('Y-m-d_H-i-s') . '.xlsx';
$filepath = sys_get_temp_dir() . '/' . $filename;
$writer->save($filepath);
return $this->json([
'result' => 1,
'filename' => $filename,
'downloadUrl' => '/api/hrm/attendance/download/' . $filename
]);
} catch (\Exception $e) {
return $this->json(['error' => $e->getMessage()], 500);
}
}
private function getAttendanceStatus(PlugHrmAttendance $attendance): string
{
$totalHours = $attendance->getTotalHours();
$overtimeHours = $attendance->getOvertimeHours();
// استاندارد 8 ساعت کار در روز
$standardHours = 8 * 60; // به دقیقه
if ($totalHours >= $standardHours) {
return 'حضور کامل';
} elseif ($totalHours >= $standardHours * 0.5) {
return 'نیمه وقت';
} elseif ($totalHours > 0) {
return 'تاخیر';
} else {
return 'غیبت';
}
}
private function calculateWorkHours($items): array
{
$totalMinutes = 0;
$overtimeMinutes = 0;
$standardWorkMinutes = 8 * 60; // 8 ساعت کار استاندارد
// مرتب کردن آیتم‌ها بر اساس زمان
usort($items, function($a, $b) {
return strtotime($a['time']) - strtotime($b['time']);
});
$entries = [];
$exits = [];
foreach ($items as $item) {
if ($item['type'] === 'ورود') {
$entries[] = $item['time'];
} elseif ($item['type'] === 'خروج') {
$exits[] = $item['time'];
}
}
// محاسبه ساعات کار
$minCount = min(count($entries), count($exits));
for ($i = 0; $i < $minCount; $i++) {
$entryTime = strtotime($entries[$i]);
$exitTime = strtotime($exits[$i]);
$workMinutes = ($exitTime - $entryTime) / 60;
$totalMinutes += $workMinutes;
}
if ($totalMinutes > $standardWorkMinutes) {
$overtimeMinutes = $totalMinutes - $standardWorkMinutes;
$totalMinutes = $standardWorkMinutes;
}
return [
'total' => $totalMinutes,
'overtime' => $overtimeMinutes
];
}
private function formatMinutesToHours($minutes): string
{
if (!$minutes) return '0:00';
$hours = floor($minutes / 60);
$mins = $minutes % 60;
return sprintf('%d:%02d', $hours, $mins);
}
}

View file

@ -147,6 +147,9 @@ class Permission
#[ORM\Column(nullable: true)]
private ?bool $importWorkflow = null;
#[ORM\Column(nullable: true)]
private ?bool $plugHrmAttendance = null;
public function getId(): ?int
{
return $this->id;
@ -679,4 +682,16 @@ class Permission
return $this;
}
public function isPlugHrmAttendance(): ?bool
{
return $this->plugHrmAttendance;
}
public function setPlugHrmAttendance(?bool $plugHrmAttendance): static
{
$this->plugHrmAttendance = $plugHrmAttendance;
return $this;
}
}

View file

@ -0,0 +1,177 @@
<?php
namespace App\Entity;
use App\Repository\PlugHrmAttendanceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: PlugHrmAttendanceRepository::class)]
class PlugHrmAttendance
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'plugHrmAttendances')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Business $business = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Person $person = null;
#[ORM\Column(length: 10)]
private ?string $date = null; // تاریخ شمسی YYYY/MM/DD
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $totalHours = 0; // ساعات کل کار (به دقیقه)
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $overtimeHours = 0; // ساعات اضافه‌کاری (به دقیقه)
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\OneToMany(mappedBy: 'attendance', targetEntity: PlugHrmAttendanceItem::class, orphanRemoval: true)]
private Collection $items;
public function __construct()
{
$this->items = new ArrayCollection();
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getBusiness(): ?Business
{
return $this->business;
}
public function setBusiness(?Business $business): static
{
$this->business = $business;
return $this;
}
public function getPerson(): ?Person
{
return $this->person;
}
public function setPerson(?Person $person): static
{
$this->person = $person;
return $this;
}
public function getDate(): ?string
{
return $this->date;
}
public function setDate(string $date): static
{
$this->date = $date;
return $this;
}
public function getTotalHours(): ?int
{
return $this->totalHours;
}
public function setTotalHours(?int $totalHours): static
{
$this->totalHours = $totalHours;
return $this;
}
public function getOvertimeHours(): ?int
{
return $this->overtimeHours;
}
public function setOvertimeHours(?int $overtimeHours): static
{
$this->overtimeHours = $overtimeHours;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeInterface $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
/**
* @return Collection<int, PlugHrmAttendanceItem>
*/
public function getItems(): Collection
{
return $this->items;
}
public function addItem(PlugHrmAttendanceItem $item): static
{
if (!$this->items->contains($item)) {
$this->items->add($item);
$item->setAttendance($this);
}
return $this;
}
public function removeItem(PlugHrmAttendanceItem $item): static
{
if ($this->items->removeElement($item)) {
if ($item->getAttendance() === $this) {
$item->setAttendance(null);
}
}
return $this;
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Entity;
use App\Repository\PlugHrmAttendanceItemRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: PlugHrmAttendanceItemRepository::class)]
class PlugHrmAttendanceItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'items')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?PlugHrmAttendance $attendance = null;
#[ORM\Column(length: 10)]
private ?string $type = null; // ورود یا خروج
#[ORM\Column(length: 5)]
private ?string $time = null; // زمان HH:MM
#[ORM\Column(type: Types::INTEGER)]
private ?int $timestamp = null; // unix timestamp
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
private ?\DateTimeInterface $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getAttendance(): ?PlugHrmAttendance
{
return $this->attendance;
}
public function setAttendance(?PlugHrmAttendance $attendance): static
{
$this->attendance = $attendance;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTime(): ?string
{
return $this->time;
}
public function setTime(string $time): static
{
$this->time = $time;
return $this;
}
public function getTimestamp(): ?int
{
return $this->timestamp;
}
public function setTimestamp(int $timestamp): static
{
$this->timestamp = $timestamp;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Repository;
use App\Entity\PlugHrmAttendanceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlugHrmAttendanceItem>
*
* @method PlugHrmAttendanceItem|null find($id, $lockMode = null, $lockVersion = null)
* @method PlugHrmAttendanceItem|null findOneBy(array $criteria, array $orderBy = null)
* @method PlugHrmAttendanceItem[] findAll()
* @method PlugHrmAttendanceItem[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class PlugHrmAttendanceItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlugHrmAttendanceItem::class);
}
public function save(PlugHrmAttendanceItem $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(PlugHrmAttendanceItem $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return PlugHrmAttendanceItem[] Returns an array of PlugHrmAttendanceItem objects
*/
public function findByAttendanceOrderedByTime($attendance): array
{
return $this->createQueryBuilder('i')
->where('i.attendance = :attendance')
->setParameter('attendance', $attendance)
->orderBy('i.time', 'ASC')
->getQuery()
->getResult();
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Repository;
use App\Entity\PlugHrmAttendance;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlugHrmAttendance>
*
* @method PlugHrmAttendance|null find($id, $lockMode = null, $lockVersion = null)
* @method PlugHrmAttendance|null findOneBy(array $criteria, array $orderBy = null)
* @method PlugHrmAttendance[] findAll()
* @method PlugHrmAttendance[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class PlugHrmAttendanceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlugHrmAttendance::class);
}
public function save(PlugHrmAttendance $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(PlugHrmAttendance $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* @return PlugHrmAttendance[] Returns an array of PlugHrmAttendance objects
*/
public function findByBusinessAndDateRange($business, $fromDate, $toDate, $personId = null): array
{
$qb = $this->createQueryBuilder('a')
->leftJoin('a.person', 'p')
->addSelect('p')
->where('a.business = :business')
->andWhere('a.date >= :fromDate')
->andWhere('a.date <= :toDate')
->setParameter('business', $business)
->setParameter('fromDate', $fromDate)
->setParameter('toDate', $toDate)
->orderBy('a.date', 'DESC')
->addOrderBy('p.nikename', 'ASC');
if ($personId) {
$qb->andWhere('a.person = :personId')
->setParameter('personId', $personId);
}
return $qb->getQuery()->getResult();
}
/**
* @return PlugHrmAttendance[] Returns an array of PlugHrmAttendance objects
*/
public function findByBusinessAndPerson($business, $person, $limit = 30): array
{
return $this->createQueryBuilder('a')
->where('a.business = :business')
->andWhere('a.person = :person')
->setParameter('business', $business)
->setParameter('person', $person)
->orderBy('a.date', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* @return PlugHrmAttendance|null Returns a single PlugHrmAttendance object
*/
public function findByBusinessAndPersonAndDate($business, $person, $date): ?PlugHrmAttendance
{
return $this->createQueryBuilder('a')
->where('a.business = :business')
->andWhere('a.person = :person')
->andWhere('a.date = :date')
->setParameter('business', $business)
->setParameter('person', $person)
->setParameter('date', $date)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -1082,6 +1082,46 @@ const router = createRouter({
component: () =>
import('../views/acc/plugins/hrm/docs/view.vue'),
},
{
path: 'hrm/attendance/list',
name: 'hrm_attendance_list',
component: () =>
import('../views/acc/plugins/hrm/attendance/list.vue'),
meta: {
'title': 'مدیریت تردد پرسنل',
'login': true,
}
},
{
path: 'hrm/attendance/mod/:id?',
name: 'hrm_attendance_mod',
component: () =>
import('../views/acc/plugins/hrm/attendance/mod.vue'),
meta: {
'title': 'افزودن/ویرایش تردد',
'login': true,
}
},
{
path: 'hrm/attendance/view/:id',
name: 'hrm_attendance_view',
component: () =>
import('../views/acc/plugins/hrm/attendance/view.vue'),
meta: {
'title': 'مشاهده جزئیات تردد',
'login': true,
}
},
{
path: 'hrm/attendance/reports',
name: 'hrm_attendance_reports',
component: () =>
import('../views/acc/plugins/hrm/attendance/reports.vue'),
meta: {
'title': 'گزارشات تردد پرسنل',
'login': true,
}
},
{
path: 'inquiry/panel',
name: 'inquiry_panel',

View file

@ -889,6 +889,19 @@ export default {
</v-tooltip>
</template>
</v-list-item>
<v-list-item to="/acc/hrm/attendance/list">
<v-list-item-title>
تردد پرسنل
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/hrm/attendance/list') }}</span>
</v-list-item-title>
<template v-slot:append v-if="permissions.plugHrmAttendance">
<v-tooltip text="افزودن تردد جدید" location="end">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-plus-box" variant="plain" to="/acc/hrm/attendance/mod/0" />
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-group v-show="isPluginActive('ghesta') && permissions.plugGhestaManager">
<template v-slot:activator="{ props }">

View file

@ -0,0 +1,333 @@
<template>
<div>
<v-toolbar color="primary" dark>
<v-toolbar-title>
<v-icon start icon="mdi-clock-check"></v-icon>
مدیریت تردد پرسنل
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn v-if="permissions.plugHrmAttendance" prepend-icon="mdi-plus" @click="$router.push('/acc/hrm/attendance/mod/0')">
افزودن تردد
</v-btn>
<v-btn prepend-icon="mdi-upload" @click="showImportDialog = true">
واردات از اکسل
</v-btn>
<v-btn prepend-icon="mdi-download" @click="exportData">
خروجی اکسل
</v-btn>
<v-btn prepend-icon="mdi-chart-line" @click="$router.push('/acc/hrm/attendance/reports')">
گزارشات
</v-btn>
</v-toolbar>
<v-container fluid class="pa-4">
<!-- فیلترها -->
<v-card class="mb-4">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<Hdatepicker v-model="filters.fromDate" label="از تاریخ" />
</v-col>
<v-col cols="12" md="3">
<Hdatepicker v-model="filters.toDate" label="تا تاریخ" />
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filters.personId"
:items="employees"
item-title="name"
item-value="id"
label="پرسنل"
clearable
prepend-inner-icon="mdi-account"
/>
</v-col>
<v-col cols="12" md="3" class="d-flex align-center">
<v-btn color="primary" @click="loadData" :loading="loading">
<v-icon start>mdi-magnify</v-icon>
جستجو
</v-btn>
<v-btn class="ms-2" @click="clearFilters">
<v-icon start>mdi-refresh</v-icon>
پاک کردن
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- جدول -->
<v-card>
<v-data-table
:headers="headers"
:items="attendances"
:loading="loading"
:items-per-page="filters.limit"
:page="filters.page"
:server-items-length="total"
@update:options="handleTableUpdate"
class="elevation-1"
>
<template v-slot:item.date="{ item }">
<span>{{ formatDate(item.date) }}</span>
</template>
<template v-slot:item.totalHours="{ item }">
<span>{{ formatMinutesToHours(item.totalHours) }}</span>
</template>
<template v-slot:item.overtimeHours="{ item }">
<span>{{ formatMinutesToHours(item.overtimeHours) }}</span>
</template>
<template v-slot:item.createdAt="{ item }">
<span>{{ item.createdAt }}</span>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
v-if="permissions.plugHrmAttendance"
icon="mdi-pencil"
size="small"
variant="text"
color="primary"
@click="$router.push(`/acc/hrm/attendance/mod/${item.id}`)"
/>
<v-btn
v-if="permissions.plugHrmAttendance"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="deleteAttendance(item)"
/>
</template>
</v-data-table>
</v-card>
</v-container>
<!-- دیالوگ واردات -->
<v-dialog v-model="showImportDialog" max-width="500">
<v-card>
<v-card-title>واردات از فایل اکسل</v-card-title>
<v-card-text>
<v-file-input
v-model="importFile"
label="انتخاب فایل اکسل"
accept=".xlsx,.xls"
prepend-icon="mdi-file-excel"
show-size
/>
<v-alert type="info" variant="tonal" class="mt-2">
<strong>فرمت فایل:</strong><br>
ستون A: کد پرسنل<br>
ستون B: تاریخ (YYYY/MM/DD)<br>
ستون C: زمان (HH:MM)<br>
ستون D: نوع (ورود/خروج)
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showImportDialog = false">انصراف</v-btn>
<v-btn color="primary" @click="importData" :loading="importing">
واردات
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- اسنکبار -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import Hdatepicker from '@/components/forms/Hdatepicker.vue';
export default {
name: 'AttendanceList',
components: {
Hdatepicker
},
data() {
return {
loading: false,
importing: false,
showImportDialog: false,
importFile: null,
attendances: [],
employees: [],
total: 0,
permissions: {},
filters: {
page: 1,
limit: 20,
fromDate: '',
toDate: '',
personId: null
},
headers: [
{ title: 'تاریخ', value: 'date', sortable: true },
{ title: 'نام پرسنل', value: 'personName', sortable: true },
{ title: 'ساعات کل کار', value: 'totalHours', sortable: true },
{ title: 'ساعات اضافه‌کاری', value: 'overtimeHours', sortable: true },
{ title: 'توضیحات', value: 'description', sortable: false },
{ title: 'تاریخ ثبت', value: 'createdAt', sortable: true },
{ title: 'عملیات', value: 'actions', sortable: false, width: '120px' }
],
snackbar: {
show: false,
text: '',
color: 'success'
}
};
},
async mounted() {
await this.loadPermissions();
await this.loadEmployees();
await this.loadData();
},
methods: {
async loadPermissions() {
try {
const response = await axios.post('/api/business/get/user/permissions');
this.permissions = response.data;
} catch (error) {
console.error('Error loading permissions:', error);
}
},
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() {
this.loading = true;
try {
const response = await axios.post('/api/hrm/attendance/list', this.filters);
this.attendances = response.data.data;
this.total = response.data.total;
} catch (error) {
console.error('Error loading data:', error);
this.showSnackbar('خطا در بارگذاری اطلاعات', 'error');
} finally {
this.loading = false;
}
},
async deleteAttendance(item) {
if (!confirm(`آیا از حذف تردد ${item.personName} برای تاریخ ${this.formatDate(item.date)} اطمینان دارید؟`)) {
return;
}
try {
await axios.post(`/api/hrm/attendance/delete/${item.id}`);
this.showSnackbar('تردد با موفقیت حذف شد', 'success');
await this.loadData();
} catch (error) {
console.error('Error deleting attendance:', error);
this.showSnackbar('خطا در حذف تردد', 'error');
}
},
async importData() {
if (!this.importFile) {
this.showSnackbar('لطفاً فایل را انتخاب کنید', 'warning');
return;
}
this.importing = true;
try {
const formData = new FormData();
formData.append('file', this.importFile);
const response = await axios.post('/api/hrm/attendance/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data.result === 1) {
this.showSnackbar(`${response.data.importedCount} رکورد با موفقیت وارد شد`, 'success');
if (response.data.errors && response.data.errors.length > 0) {
console.warn('Import errors:', response.data.errors);
}
this.showImportDialog = false;
this.importFile = null;
await this.loadData();
}
} catch (error) {
console.error('Error importing data:', error);
this.showSnackbar('خطا در واردات فایل', 'error');
} finally {
this.importing = false;
}
},
async exportData() {
try {
const response = await axios.post('/api/hrm/attendance/export', this.filters);
if (response.data.result === 1) {
// دانلود فایل
const link = document.createElement('a');
link.href = response.data.downloadUrl;
link.download = response.data.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showSnackbar('فایل با موفقیت دانلود شد', 'success');
}
} catch (error) {
console.error('Error exporting data:', error);
this.showSnackbar('خطا در خروجی فایل', 'error');
}
},
handleTableUpdate(options) {
this.filters.page = options.page;
this.filters.limit = options.itemsPerPage;
this.loadData();
},
clearFilters() {
this.filters = {
page: 1,
limit: 20,
fromDate: '',
toDate: '',
personId: null
};
this.loadData();
},
formatDate(date) {
if (!date) return '';
return date.replace(/\//g, '/');
},
formatMinutesToHours(minutes) {
if (!minutes) return '0:00';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
},
showSnackbar(text, color = 'success') {
this.snackbar = {
show: true,
text,
color
};
}
}
};
</script>

View file

@ -0,0 +1,367 @@
<template>
<div>
<v-toolbar color="primary" dark>
<v-toolbar-title>
<v-icon start icon="mdi-clock-check"></v-icon>
{{ isEdit ? 'ویرایش تردد' : 'افزودن تردد جدید' }}
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn prepend-icon="mdi-arrow-left" @click="$router.push('/acc/hrm/attendance/list')">
بازگشت
</v-btn>
</v-toolbar>
<v-container fluid class="pa-4">
<v-card>
<v-card-text>
<v-form ref="form" @submit.prevent="save">
<v-row>
<v-col cols="12" md="4">
<v-select
v-model="form.personId"
:items="employees"
item-title="name"
item-value="id"
label="پرسنل *"
required
prepend-inner-icon="mdi-account"
:rules="[v => !!v || 'انتخاب پرسنل الزامی است']"
/>
</v-col>
<v-col cols="12" md="4">
<Hdatepicker
v-model="form.date"
label="تاریخ *"
:rules="[v => !!v || 'تاریخ الزامی است']"
/>
</v-col>
<v-col cols="12" md="4">
<v-textarea
v-model="form.description"
label="توضیحات"
rows="1"
prepend-inner-icon="mdi-text"
/>
</v-col>
</v-row>
<!-- آیتمهای تردد -->
<v-card class="mt-4" variant="outlined">
<v-card-title class="text-h6">
<v-icon start>mdi-clock-outline</v-icon>
ورود و خروج
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="addItem"
size="small"
>
افزودن
</v-btn>
</v-card-title>
<v-card-text>
<v-row v-for="(item, index) in form.items" :key="index" class="mb-2">
<v-col cols="12" md="3">
<v-select
v-model="item.type"
:items="typeOptions"
label="نوع"
required
:rules="[v => !!v || 'نوع الزامی است']"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="item.time"
label="زمان (HH:MM)"
type="time"
required
:rules="[v => !!v || 'زمان الزامی است']"
/>
</v-col>
<v-col cols="12" md="5">
<v-text-field
v-model="item.note"
label="یادداشت"
placeholder="توضیحات اختیاری"
/>
</v-col>
<v-col cols="12" md="1">
<v-btn
icon="mdi-delete"
color="error"
variant="text"
@click="removeItem(index)"
:disabled="form.items.length <= 1"
/>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- خلاصه -->
<v-card class="mt-4" variant="outlined">
<v-card-title class="text-h6">
<v-icon start>mdi-calculator</v-icon>
خلاصه ساعات کار
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<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">
<div class="text-h6">{{ summary.entries }}</div>
<div class="text-caption">تعداد ورود</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="tonal" color="info">
<v-card-text class="text-center">
<div class="text-h6">{{ summary.exits }}</div>
<div class="text-caption">تعداد خروج</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-row class="mt-4">
<v-col cols="12" class="text-center">
<v-btn
type="submit"
color="primary"
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-col>
</v-row>
</v-form>
</v-card-text>
</v-card>
</v-container>
<!-- اسنکبار -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import Hdatepicker from '@/components/forms/Hdatepicker.vue';
export default {
name: 'AttendanceMod',
components: {
Hdatepicker
},
data() {
return {
isEdit: false,
saving: false,
employees: [],
form: {
personId: null,
date: '',
description: '',
items: [
{
type: 'ورود',
time: '08:00',
note: ''
}
]
},
typeOptions: [
{ title: 'ورود', value: 'ورود' },
{ title: 'خروج', value: 'خروج' }
],
snackbar: {
show: false,
text: '',
color: 'success'
}
};
},
computed: {
summary() {
const entries = this.form.items.filter(item => item.type === 'ورود').length;
const exits = this.form.items.filter(item => item.type === 'خروج').length;
// محاسبه ساعات کار (ساده)
let totalMinutes = 0;
const standardWorkMinutes = 8 * 60; // 8 ساعت
if (entries > 0 && exits > 0) {
// محاسبه ساده: فرض بر این است که اولین ورود و آخرین خروج
const sortedItems = [...this.form.items].sort((a, b) => a.time.localeCompare(b.time));
const firstEntry = sortedItems.find(item => item.type === 'ورود');
const lastExit = sortedItems.reverse().find(item => item.type === 'خروج');
if (firstEntry && lastExit) {
const entryTime = this.timeToMinutes(firstEntry.time);
const exitTime = this.timeToMinutes(lastExit.time);
totalMinutes = exitTime - entryTime;
}
}
const overtimeMinutes = totalMinutes > standardWorkMinutes ? totalMinutes - standardWorkMinutes : 0;
const regularMinutes = totalMinutes > standardWorkMinutes ? standardWorkMinutes : totalMinutes;
return {
totalHours: regularMinutes,
overtimeHours: overtimeMinutes,
entries,
exits
};
}
},
async mounted() {
await this.loadEmployees();
await this.loadData();
},
methods: {
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() {
const id = this.$route.params.id;
if (id && id !== '0') {
this.isEdit = true;
try {
const response = await axios.post(`/api/hrm/attendance/get/${id}`);
const data = response.data;
this.form.personId = data.personId;
this.form.date = data.date;
this.form.description = data.description;
this.form.items = data.items.map(item => ({
type: item.type,
time: item.time,
note: ''
}));
} catch (error) {
console.error('Error loading data:', error);
this.showSnackbar('خطا در بارگذاری اطلاعات', 'error');
}
}
},
addItem() {
this.form.items.push({
type: 'ورود',
time: '08:00',
note: ''
});
},
removeItem(index) {
if (this.form.items.length > 1) {
this.form.items.splice(index, 1);
}
},
async save() {
const { valid } = await this.$refs.form.validate();
if (!valid) return;
this.saving = true;
try {
const id = this.$route.params.id;
const url = id && id !== '0' ? `/api/hrm/attendance/mod/${id}` : '/api/hrm/attendance/mod/0';
// تبدیل آیتمها به فرمت مورد نیاز
const items = this.form.items.map(item => ({
type: item.type,
time: item.time,
timestamp: this.getTimestamp(this.form.date, item.time)
}));
const payload = {
...this.form,
items
};
const response = await axios.post(url, payload);
if (response.data.result === 1) {
this.showSnackbar(
this.isEdit ? 'تردد با موفقیت ویرایش شد' : 'تردد با موفقیت ثبت شد',
'success'
);
this.$router.push('/acc/hrm/attendance/list');
}
} catch (error) {
console.error('Error saving data:', error);
this.showSnackbar('خطا در ذخیره اطلاعات', 'error');
} finally {
this.saving = false;
}
},
timeToMinutes(time) {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
},
getTimestamp(date, time) {
// تبدیل تاریخ و زمان شمسی به timestamp
const dateTime = `${date} ${time}`;
// اینجا باید از سرویس Jdate استفاده شود
return Math.floor(Date.now() / 1000); // فعلاً timestamp فعلی
},
formatMinutesToHours(minutes) {
if (!minutes) return '0:00';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}:${mins.toString().padStart(2, '0')}`;
},
showSnackbar(text, color = 'success') {
this.snackbar = {
show: true,
text,
color
};
}
}
};
</script>

View file

@ -0,0 +1,314 @@
<template>
<div>
<v-toolbar color="primary" dark>
<v-toolbar-title>
<v-icon start icon="mdi-chart-line"></v-icon>
گزارشات تردد پرسنل
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn prepend-icon="mdi-arrow-right" @click="$router.back()">
بازگشت
</v-btn>
</v-toolbar>
<v-container fluid class="pa-4">
<!-- فیلترها -->
<v-card class="mb-4">
<v-card-title>فیلترهای گزارش</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-select
v-model="reportType"
:items="reportTypes"
item-title="title"
item-value="value"
label="نوع گزارش"
prepend-inner-icon="mdi-chart-bar"
/>
</v-col>
<v-col cols="12" md="3">
<Hdatepicker v-model="filters.fromDate" label="از تاریخ" />
</v-col>
<v-col cols="12" md="3">
<Hdatepicker v-model="filters.toDate" label="تا تاریخ" />
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filters.personId"
:items="employees"
item-title="name"
item-value="id"
label="پرسنل"
clearable
prepend-inner-icon="mdi-account"
/>
</v-col>
<v-col cols="12" class="d-flex justify-center">
<v-btn color="primary" @click="generateReport" :loading="loading" class="me-2">
<v-icon start>mdi-chart-line</v-icon>
تولید گزارش
</v-btn>
<v-btn @click="exportReport" :loading="exporting" :disabled="!reportData.length">
<v-icon start>mdi-download</v-icon>
خروجی اکسل
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- خلاصه آمار -->
<v-row v-if="summary" class="mb-4">
<v-col cols="12" md="3">
<v-card color="primary" dark>
<v-card-text class="text-center">
<div class="text-h4">{{ summary.totalDays }}</div>
<div class="text-subtitle-1">کل روزهای کاری</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="success" dark>
<v-card-text class="text-center">
<div class="text-h4">{{ formatMinutesToHours(summary.totalHours) }}</div>
<div class="text-subtitle-1">کل ساعات کار</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="warning" dark>
<v-card-text class="text-center">
<div class="text-h4">{{ formatMinutesToHours(summary.totalOvertime) }}</div>
<div class="text-subtitle-1">کل اضافهکاری</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card color="info" dark>
<v-card-text class="text-center">
<div class="text-h4">{{ summary.averageHours }}</div>
<div class="text-subtitle-1">میانگین ساعات روزانه</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- جدول گزارش -->
<v-card v-if="reportData.length > 0">
<v-card-title>{{ getReportTitle() }}</v-card-title>
<v-data-table
:headers="getTableHeaders()"
:items="reportData"
:loading="loading"
class="elevation-1"
>
<template v-slot:item.date="{ item }">
<span>{{ formatDate(item.date) }}</span>
</template>
<template v-slot:item.totalHours="{ item }">
<span>{{ formatMinutesToHours(item.totalHours) }}</span>
</template>
<template v-slot:item.overtimeHours="{ item }">
<span>{{ formatMinutesToHours(item.overtimeHours) }}</span>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor(item.status)"
size="small"
>
{{ item.status }}
</v-chip>
</template>
</v-data-table>
</v-card>
<!-- پیام خالی -->
<v-card v-else-if="!loading" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-chart-line</v-icon>
<div class="text-h6 text-grey">هیچ دادهای برای نمایش وجود ندارد</div>
<div class="text-body-2 text-grey-lighten-1">لطفاً فیلترها را تنظیم کرده و گزارش را تولید کنید</div>
</v-card>
</v-container>
<!-- اسنکبار -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import Hdatepicker from '@/components/forms/Hdatepicker.vue';
export default {
name: 'AttendanceReports',
components: {
Hdatepicker
},
data() {
return {
loading: false,
exporting: false,
reportType: 'daily',
reportTypes: [
{ title: 'گزارش روزانه', value: 'daily' },
{ title: 'گزارش ماهانه', value: 'monthly' },
{ title: 'گزارش اضافه‌کاری', value: 'overtime' },
{ title: 'گزارش تاخیر و غیبت', value: 'absence' }
],
filters: {
fromDate: '',
toDate: '',
personId: null
},
employees: [],
reportData: [],
summary: null,
snackbar: {
show: false,
text: '',
color: 'success'
}
};
},
async mounted() {
await this.loadEmployees();
},
methods: {
async loadEmployees() {
try {
const response = await axios.post('/api/hrm/attendance/employees');
this.employees = response.data;
} catch (error) {
console.error('Error loading employees:', error);
}
},
async generateReport() {
if (!this.filters.fromDate || !this.filters.toDate) {
this.showSnackbar('لطفاً بازه زمانی را مشخص کنید', 'warning');
return;
}
try {
this.loading = true;
const response = await axios.post('/api/hrm/attendance/reports', {
type: this.reportType,
fromDate: this.filters.fromDate,
toDate: this.filters.toDate,
personId: this.filters.personId
});
if (response.data.result === 1) {
this.reportData = response.data.data;
this.summary = response.data.summary;
} else {
this.showSnackbar('خطا در تولید گزارش', 'error');
}
} catch (error) {
console.error('Error generating report:', error);
this.showSnackbar('خطا در تولید گزارش', 'error');
} finally {
this.loading = false;
}
},
async exportReport() {
try {
this.exporting = true;
const response = await axios.post('/api/hrm/attendance/export-report', {
type: this.reportType,
fromDate: this.filters.fromDate,
toDate: this.filters.toDate,
personId: this.filters.personId
});
if (response.data.result === 1) {
// دانلود فایل
const link = document.createElement('a');
link.href = response.data.downloadUrl;
link.download = response.data.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showSnackbar('فایل با موفقیت دانلود شد', 'success');
}
} catch (error) {
console.error('Error exporting report:', error);
this.showSnackbar('خطا در خروجی فایل', 'error');
} finally {
this.exporting = false;
}
},
getReportTitle() {
const typeMap = {
daily: 'گزارش روزانه تردد',
monthly: 'گزارش ماهانه تردد',
overtime: 'گزارش اضافه‌کاری',
absence: 'گزارش تاخیر و غیبت'
};
return typeMap[this.reportType] || 'گزارش تردد';
},
getTableHeaders() {
const baseHeaders = [
{ title: 'تاریخ', value: 'date', sortable: true },
{ title: 'نام پرسنل', value: 'personName', sortable: true },
{ title: 'ساعات کل کار', value: 'totalHours', sortable: true },
{ title: 'ساعات اضافه‌کاری', value: 'overtimeHours', sortable: true }
];
if (this.reportType === 'absence') {
baseHeaders.push(
{ title: 'وضعیت', value: 'status', sortable: true },
{ title: 'توضیحات', value: 'description', sortable: false }
);
}
return baseHeaders;
},
getStatusColor(status) {
const colorMap = {
'حضور کامل': 'success',
'تاخیر': 'warning',
'غیبت': 'error',
'نیمه وقت': 'info'
};
return colorMap[status] || 'default';
},
formatDate(date) {
if (!date) return '';
return date;
},
formatMinutesToHours(minutes) {
if (!minutes) return '0 ساعت';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours} ساعت و ${mins} دقیقه`;
},
showSnackbar(text, color = 'success') {
this.snackbar = {
show: true,
text,
color
};
}
}
};
</script>
<style scoped>
.v-data-table {
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,195 @@
<template>
<div>
<v-toolbar color="primary" dark>
<v-toolbar-title>
<v-icon start icon="mdi-eye"></v-icon>
مشاهده جزئیات تردد
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn v-if="permissions.plugHrmAttendance" prepend-icon="mdi-pencil" @click="$router.push(`/acc/hrm/attendance/mod/${attendance.id}`)">
ویرایش
</v-btn>
<v-btn prepend-icon="mdi-arrow-right" @click="$router.back()">
بازگشت
</v-btn>
</v-toolbar>
<v-container fluid class="pa-4" v-if="!loading">
<v-row>
<v-col cols="12" md="6">
<v-card>
<v-card-title>اطلاعات کلی</v-card-title>
<v-card-text>
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-icon icon="mdi-account"></v-icon>
</template>
<v-list-item-title>نام پرسنل</v-list-item-title>
<v-list-item-subtitle>{{ attendance.personName }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon icon="mdi-calendar"></v-icon>
</template>
<v-list-item-title>تاریخ</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(attendance.date) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon icon="mdi-clock"></v-icon>
</template>
<v-list-item-title>ساعات کل کار</v-list-item-title>
<v-list-item-subtitle>{{ formatMinutesToHours(attendance.totalHours) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon icon="mdi-clock-plus"></v-icon>
</template>
<v-list-item-title>ساعات اضافهکاری</v-list-item-title>
<v-list-item-subtitle>{{ formatMinutesToHours(attendance.overtimeHours) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="attendance.description">
<template v-slot:prepend>
<v-icon icon="mdi-text"></v-icon>
</template>
<v-list-item-title>توضیحات</v-list-item-title>
<v-list-item-subtitle>{{ attendance.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card>
<v-card-title>جزئیات ورود و خروج</v-card-title>
<v-card-text>
<v-timeline v-if="attendance.items && attendance.items.length > 0">
<v-timeline-item
v-for="(item, index) in attendance.items"
:key="index"
:color="item.type === 'ورود' ? 'success' : 'error'"
:icon="item.type === 'ورود' ? 'mdi-login' : 'mdi-logout'"
>
<template v-slot:opposite>
<span class="text-caption">{{ item.time }}</span>
</template>
<v-card>
<v-card-text>
<div class="d-flex align-center">
<v-icon :color="item.type === 'ورود' ? 'success' : 'error'" class="me-2">
{{ item.type === 'ورود' ? 'mdi-login' : 'mdi-logout' }}
</v-icon>
<span class="font-weight-medium">{{ item.type }}</span>
</div>
<div class="text-caption mt-1">
ثبت شده در: {{ formatDateTime(item.createdAt) }}
</div>
</v-card-text>
</v-card>
</v-timeline-item>
</v-timeline>
<v-alert v-else type="info" variant="tonal">
هیچ رکورد ورود و خروجی ثبت نشده است.
</v-alert>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
<v-container v-else class="d-flex justify-center align-center" style="height: 400px;">
<v-progress-circular indeterminate color="primary" size="64"></v-progress-circular>
</v-container>
<!-- اسنکبار -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'AttendanceView',
data() {
return {
loading: true,
attendance: {},
permissions: {},
snackbar: {
show: false,
text: '',
color: 'success'
}
};
},
async mounted() {
await this.loadPermissions();
await this.loadData();
},
methods: {
async loadPermissions() {
try {
const response = await axios.post('/api/business/get/user/permissions');
this.permissions = response.data;
} catch (error) {
console.error('Error loading permissions:', error);
}
},
async loadData() {
try {
this.loading = true;
const response = await axios.post('/api/hrm/attendance/get/' + this.$route.params.id);
if (response.data.result === 1) {
this.attendance = response.data.data;
} else {
this.showSnackbar('خطا در بارگذاری اطلاعات', 'error');
}
} catch (error) {
console.error('Error loading attendance data:', error);
this.showSnackbar('خطا در بارگذاری اطلاعات', 'error');
} finally {
this.loading = false;
}
},
formatDate(date) {
if (!date) return '';
return date;
},
formatMinutesToHours(minutes) {
if (!minutes) return '0 ساعت';
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours} ساعت و ${mins} دقیقه`;
},
formatDateTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('fa-IR');
},
showSnackbar(text, color = 'success') {
this.snackbar = {
show: true,
text,
color
};
}
}
};
</script>
<style scoped>
.v-timeline-item__opposite {
min-width: 80px;
}
</style>

View file

@ -549,6 +549,18 @@
:disabled="loadingSwitches.plugHrmDocs"
></v-switch>
</v-list-item>
<v-list-item>
<v-switch
v-model="info.plugHrmAttendance"
label="مدیریت تردد پرسنل"
@change="savePerms('plugHrmAttendance')"
hide-details
color="success"
density="comfortable"
:loading="loadingSwitches.plugHrmAttendance"
:disabled="loadingSwitches.plugHrmAttendance"
></v-switch>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
@ -792,6 +804,7 @@ export default {
plugNoghreSell: false,
plugCCAdmin: false,
plugHrmDocs: false,
plugHrmAttendance: false,
plugGhestaManager: false,
plugTaxSettings: false,
inquiry: false,