This commit is contained in:
Hesabix 2025-08-06 17:01:57 +00:00
commit 3336379e23
20 changed files with 3024 additions and 3 deletions

View file

@ -0,0 +1,182 @@
<?php
namespace App\Service;
use App\Entity\Hook;
use App\Entity\Business;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
class HookService
{
private $entityManager;
private $httpClient;
public function __construct(
EntityManagerInterface $entityManager,
HttpClientInterface $httpClient
) {
$this->entityManager = $entityManager;
$this->httpClient = $httpClient;
}
/**
* دریافت تمام هوک‌های یک کسب و کار
*/
public function getHooksByBusiness(Business $business): array
{
return $this->entityManager->getRepository(Hook::class)->findBy([
'bid' => $business
]);
}
/**
* ارسال داده به تمام هوک‌های یک کسب و کار
*/
public function sendToHooks(Business $business, array $data, string $event = 'general'): array
{
$hooks = $this->getHooksByBusiness($business);
$results = [];
foreach ($hooks as $hook) {
$result = $this->sendToHook($hook, $data, $event);
$results[] = [
'hook_id' => $hook->getId(),
'url' => $hook->getUrl(),
'success' => $result['success'],
'response' => $result['response'],
'error' => $result['error'] ?? null
];
}
return $results;
}
/**
* ارسال داده به یک هوک خاص
*/
public function sendToHook(Hook $hook, array $data, string $event = 'general'): array
{
$url = $hook->getUrl();
$password = $hook->getPassword();
// آماده‌سازی داده‌های ارسالی
$payload = [
'event' => $event,
'timestamp' => time(),
'data' => $data,
'password' => $password
];
try {
$response = $this->httpClient->request('POST', $url, [
'headers' => [
'Content-Type' => 'application/json',
'User-Agent' => 'Hesabix-Hook-Service/1.0'
],
'json' => $payload,
'timeout' => 10,
'max_redirects' => 3
]);
$statusCode = $response->getStatusCode();
$content = $response->getContent(false);
if ($statusCode >= 200 && $statusCode < 300) {
return [
'success' => true,
'response' => json_decode($content, true) ?: $content,
'status_code' => $statusCode
];
} else {
return [
'success' => false,
'error' => "HTTP Error: {$statusCode}",
'response' => $content,
'status_code' => $statusCode
];
}
} catch (\Throwable $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'status_code' => 0
];
}
}
/**
* ارسال اعلان تغییر شخص
*/
public function sendPersonChange(Business $business, array $personData, string $action = 'update'): array
{
$data = [
'action' => $action,
'person' => $personData,
'business_id' => $business->getId(),
'business_name' => $business->getName()
];
return $this->sendToHooks($business, $data, 'person_change');
}
/**
* ارسال اعلان تغییر کالا
*/
public function sendCommodityChange(Business $business, array $commodityData, string $action = 'update'): array
{
$data = [
'action' => $action,
'commodity' => $commodityData,
'business_id' => $business->getId(),
'business_name' => $business->getName()
];
return $this->sendToHooks($business, $data, 'commodity_change');
}
/**
* ارسال اعلان تغییر فاکتور
*/
public function sendInvoiceChange(Business $business, array $invoiceData, string $action = 'update'): array
{
$data = [
'action' => $action,
'invoice' => $invoiceData,
'business_id' => $business->getId(),
'business_name' => $business->getName()
];
return $this->sendToHooks($business, $data, 'invoice_change');
}
/**
* ارسال اعلان مالیاتی
*/
public function sendTaxNotification(Business $business, array $taxData, string $action = 'send'): array
{
$data = [
'action' => $action,
'tax_invoice' => $taxData,
'business_id' => $business->getId(),
'business_name' => $business->getName()
];
return $this->sendToHooks($business, $data, 'tax_notification');
}
/**
* تست اتصال به هوک
*/
public function testHook(Hook $hook): array
{
$testData = [
'test' => true,
'message' => 'تست اتصال هوک',
'timestamp' => time()
];
return $this->sendToHook($hook, $testData, 'test');
}
}

View file

@ -545,6 +545,7 @@ class BusinessController extends AbstractController
'plugHrmDocs' => true, 'plugHrmDocs' => true,
'plugGhestaManager' => true, 'plugGhestaManager' => true,
'plugTaxSettings' => true, 'plugTaxSettings' => true,
'plugWarranty' => true,
'inquiry' => true, 'inquiry' => true,
'ai' => true, 'ai' => true,
]; ];
@ -591,6 +592,7 @@ class BusinessController extends AbstractController
'plugHrmDocs' => $perm->isPlugHrmDocs(), 'plugHrmDocs' => $perm->isPlugHrmDocs(),
'plugGhestaManager' => $perm->isPlugGhestaManager(), 'plugGhestaManager' => $perm->isPlugGhestaManager(),
'plugTaxSettings' => $perm->isPlugTaxSettings(), 'plugTaxSettings' => $perm->isPlugTaxSettings(),
'plugWarranty' => $perm->isPlugWarrantyManager(),
'inquiry' => $perm->isInquiry(), 'inquiry' => $perm->isInquiry(),
'ai' => $perm->isAi(), 'ai' => $perm->isAi(),
]; ];
@ -662,6 +664,7 @@ class BusinessController extends AbstractController
$perm->setPlugRepservice($params['plugRepservice']); $perm->setPlugRepservice($params['plugRepservice']);
$perm->setPlugHrmDocs($params['plugHrmDocs']); $perm->setPlugHrmDocs($params['plugHrmDocs']);
$perm->setPlugGhestaManager($params['plugGhestaManager']); $perm->setPlugGhestaManager($params['plugGhestaManager']);
$perm->setPlugWarrantyManager($params['plugWarranty'] ?? false);
$perm->setPlugTaxSettings($params['plugTaxSettings']); $perm->setPlugTaxSettings($params['plugTaxSettings']);
$perm->setInquiry($params['inquiry']); $perm->setInquiry($params['inquiry']);
$perm->setAi($params['ai']); $perm->setAi($params['ai']);

View file

@ -182,7 +182,23 @@ class CommodityController extends AbstractController
]); ]);
$res = []; $res = [];
foreach ($items as $item) { foreach ($items as $item) {
$res[] = Explore::ExploreCommodity($item); $temp = Explore::ExploreCommodity($item);
if (!$item->isKhadamat()) {
$rows = $entityManager->getRepository('App\Entity\HesabdariRow')->findBy([
'bid' => $acc['bid'],
'commodity' => $item
]);
$count = 0;
foreach ($rows as $row) {
if ($row->getDoc()->getType() === 'buy' || $row->getDoc()->getType() === 'open_balance') {
$count += $row->getCommdityCount();
} else {
$count -= $row->getCommdityCount();
}
}
$temp['count'] = $count;
}
$res[] = $temp;
} }
return $this->json($extractor->operationSuccess([ return $this->json($extractor->operationSuccess([
'List' => $res, 'List' => $res,

View file

@ -470,6 +470,15 @@ class PluginController extends AbstractController
'icon' => ' taxplugin.jpg', 'icon' => ' taxplugin.jpg',
'defaultOn' => null, 'defaultOn' => null,
], ],
// [
// 'name' => 'مدیریت گارانتی',
// 'code' => 'warranty',
// 'timestamp' => '32104000',
// 'timelabel' => 'یک سال',
// 'price' => '200000',
// 'icon' => 'warranty.png',
// 'defaultOn' => null,
// ],
]; ];
$repo = $entityManager->getRepository(PluginProdect::class); $repo = $entityManager->getRepository(PluginProdect::class);

View file

@ -0,0 +1,556 @@
<?php
namespace App\Controller\Plugins;
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\PlugWarrantySerial;
use App\Entity\Commodity;
use App\Service\Access;
use App\Service\Log;
use App\Service\Provider;
use App\Service\Jdate;
class PlugWarrantyController extends AbstractController
{
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
private function updateExpiredSerials(EntityManagerInterface $entityManager, $businessId): void
{
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
$jdate = new Jdate();
$today = $jdate->GetTodayDate();
$expiredSerials = $repository->createQueryBuilder('s')
->where('s.bid = :businessId')
->andWhere('s.status = :status')
->andWhere('s.warrantyEndDate IS NOT NULL')
->andWhere('s.warrantyEndDate < :today')
->setParameter('businessId', $businessId)
->setParameter('status', 'active')
->setParameter('today', $today)
->getQuery()
->getResult();
foreach ($expiredSerials as $serial) {
$serial->setStatus('expired');
}
if (!empty($expiredSerials)) {
$entityManager->flush();
}
}
private function checkAndUpdateSerialStatus($serial, EntityManagerInterface $entityManager): void
{
$jdate = new Jdate();
$today = $jdate->GetTodayDate();
$warrantyEndDate = $serial->getWarrantyEndDate();
if ($serial->getStatus() === 'active' &&
$warrantyEndDate &&
$warrantyEndDate < $today) {
$serial->setStatus('expired');
$entityManager->flush();
}
}
#[Route('/api/plugins/warranty/serials', name: 'plugin_warranty_serials', methods: ['GET'])]
public function plugin_warranty_serials(EntityManagerInterface $entityManager, Access $access, Request $request): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$this->updateExpiredSerials($entityManager, $acc['bid']);
$page = $request->query->get('page', 1);
$limit = $request->query->get('limit', 20);
$status = $request->query->get('status');
$commodityId = $request->query->get('commodity_id');
$search = $request->query->get('search');
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
if ($search) {
$serials = $repository->searchSerials($acc['bid'], $search);
} elseif ($status) {
$serials = $repository->findByStatus($acc['bid'], $status);
} elseif ($commodityId) {
$serials = $repository->findByCommodity($acc['bid'], $commodityId);
} else {
$serials = $repository->findByBusiness($acc['bid']);
}
$data = [];
foreach($serials as $serial){
$commodity = $serial->getCommodity();
$submitter = $serial->getSubmitter();
$data[] = [
'id' => $serial->getId(),
'serialNumber' => $serial->getSerialNumber(),
'commodity' => $commodity ? [
'id' => $commodity->getId(),
'name' => $commodity->getName(),
'code' => $commodity->getCode()
] : null,
'dateSubmit' => $serial->getDateSubmit(),
'description' => $serial->getDescription(),
'warrantyStartDate' => $serial->getWarrantyStartDate(),
'warrantyEndDate' => $serial->getWarrantyEndDate(),
'status' => $serial->getStatus(),
'notes' => $serial->getNotes(),
'submitter' => $submitter ? [
'id' => $submitter->getId(),
'name' => $submitter->getFullName()
] : null
];
}
return $this->json($data);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/{id}', name: 'plugin_warranty_serial', methods: ['GET'])]
public function plugin_warranty_serial(EntityManagerInterface $entityManager, Access $access, $id): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid']
]);
if(!$serial)
throw $this->createNotFoundException();
$this->checkAndUpdateSerialStatus($serial, $entityManager);
$data = [
'id' => $serial->getId(),
'serialNumber' => $serial->getSerialNumber(),
'commodity' => [
'id' => $serial->getCommodity()->getId(),
'name' => $serial->getCommodity()->getName(),
'code' => $serial->getCommodity()->getCode()
],
'dateSubmit' => $serial->getDateSubmit(),
'description' => $serial->getDescription(),
'warrantyStartDate' => $serial->getWarrantyStartDate(),
'warrantyEndDate' => $serial->getWarrantyEndDate(),
'status' => $serial->getStatus(),
'notes' => $serial->getNotes(),
'submitter' => [
'id' => $serial->getSubmitter()->getId(),
'name' => $serial->getSubmitter()->getFullName()
]
];
return $this->json($data);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/add', name: 'plugin_warranty_serial_add', methods: ['POST'])]
public function plugin_warranty_serial_add(Request $request, EntityManagerInterface $entityManager, Access $access, Log $log): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
if(!array_key_exists('serialNumber', $params) || !array_key_exists('commodity_id', $params))
throw $this->createAccessDeniedException('پارامترهای ناقص');
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
if($repository->isSerialNumberExists($params['serialNumber'], $acc['bid'])) {
return $this->json(['error' => 'شماره سریال تکراری است'], 400);
}
$commodity = $entityManager->getRepository(Commodity::class)->findOneBy([
'id' => $params['commodity_id'],
'bid' => $acc['bid']
]);
if(!$commodity) {
return $this->json(['error' => 'محصول یافت نشد'], 400);
}
$serial = new PlugWarrantySerial();
$serial->setSerialNumber($params['serialNumber']);
$serial->setCommodity($commodity);
$serial->setBid($acc['bid']);
$serial->setSubmitter($this->getUser());
$serial->setDescription($params['description'] ?? null);
$serial->setWarrantyStartDate($params['warrantyStartDate'] ?? null);
$serial->setWarrantyEndDate($params['warrantyEndDate'] ?? null);
$serial->setStatus($params['status'] ?? 'active');
$serial->setNotes($params['notes'] ?? null);
$entityManager->persist($serial);
$entityManager->flush();
$log->insert(
'گارانتی',
'افزودن سریال جدید: ' . $params['serialNumber'],
$this->getUser(),
$acc['bid']
);
return $this->json([
'success' => true,
'message' => 'سریال با موفقیت افزوده شد',
'id' => $serial->getId()
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/edit/{id}', name: 'plugin_warranty_serial_edit', methods: ['POST'])]
public function plugin_warranty_serial_edit(Request $request, EntityManagerInterface $entityManager, Access $access, $id, Log $log): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid']
]);
if(!$serial)
throw $this->createNotFoundException();
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
if(array_key_exists('serialNumber', $params)) {
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
$existingSerial = $repository->createQueryBuilder('p')
->andWhere('p.serialNumber = :serialNumber')
->andWhere('p.bid = :bid')
->andWhere('p.id != :id')
->setParameter('serialNumber', $params['serialNumber'])
->setParameter('bid', $acc['bid'])
->setParameter('id', $id)
->getQuery()
->getOneOrNullResult();
if($existingSerial) {
return $this->json(['error' => 'شماره سریال تکراری است'], 400);
}
$serial->setSerialNumber($params['serialNumber']);
}
if(array_key_exists('commodity_id', $params)) {
$commodity = $entityManager->getRepository(Commodity::class)->findOneBy([
'id' => $params['commodity_id'],
'bid' => $acc['bid']
]);
if(!$commodity) {
return $this->json(['error' => 'محصول یافت نشد'], 400);
}
$serial->setCommodity($commodity);
}
if(array_key_exists('description', $params)) {
$serial->setDescription($params['description']);
}
if(array_key_exists('warrantyStartDate', $params)) {
$serial->setWarrantyStartDate($params['warrantyStartDate']);
}
if(array_key_exists('warrantyEndDate', $params)) {
$serial->setWarrantyEndDate($params['warrantyEndDate']);
}
if(array_key_exists('status', $params)) {
$serial->setStatus($params['status']);
}
if(array_key_exists('notes', $params)) {
$serial->setNotes($params['notes']);
}
$entityManager->flush();
$log->insert(
'گارانتی',
'ویرایش سریال: ' . $serial->getSerialNumber(),
$this->getUser(),
$acc['bid']
);
return $this->json([
'success' => true,
'message' => 'سریال با موفقیت ویرایش شد'
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/{id}', name: 'plugin_warranty_serial_delete', methods: ['DELETE'])]
public function plugin_warranty_serial_delete(EntityManagerInterface $entityManager, Access $access, $id, Log $log): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$serial = $entityManager->getRepository(PlugWarrantySerial::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid']
]);
if(!$serial)
throw $this->createNotFoundException();
$serialNumber = $serial->getSerialNumber();
$entityManager->remove($serial);
$entityManager->flush();
$log->insert(
'گارانتی',
'حذف سریال: ' . $serialNumber,
$this->getUser(),
$acc['bid']
);
return $this->json([
'success' => true,
'message' => 'سریال با موفقیت حذف شد'
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/bulk-import', name: 'plugin_warranty_serial_bulk_import', methods: ['POST'])]
public function plugin_warranty_serial_bulk_import(Request $request, EntityManagerInterface $entityManager, Access $access, Log $log): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true);
}
if(!array_key_exists('serials', $params) || !is_array($params['serials']))
throw $this->createAccessDeniedException('داده‌های نامعتبر');
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
$commodityRepo = $entityManager->getRepository(Commodity::class);
$successCount = 0;
$errorCount = 0;
$errors = [];
foreach($params['serials'] as $index => $serialData) {
try {
if(!array_key_exists('serialNumber', $serialData) || !array_key_exists('commodity_id', $serialData)) {
$errors[] = "ردیف " . ($index + 1) . ": پارامترهای ناقص";
$errorCount++;
continue;
}
if($repository->isSerialNumberExists($serialData['serialNumber'], $acc['bid'])) {
$errors[] = "ردیف " . ($index + 1) . ": شماره سریال تکراری است";
$errorCount++;
continue;
}
$commodity = $commodityRepo->findOneBy([
'id' => $serialData['commodity_id'],
'bid' => $acc['bid']
]);
if(!$commodity) {
$errors[] = "ردیف " . ($index + 1) . ": محصول یافت نشد";
$errorCount++;
continue;
}
$serial = new PlugWarrantySerial();
$serial->setSerialNumber($serialData['serialNumber']);
$serial->setCommodity($commodity);
$serial->setBid($acc['bid']);
$serial->setSubmitter($this->getUser());
$serial->setDescription($serialData['description'] ?? null);
$serial->setWarrantyStartDate($serialData['warrantyStartDate'] ?? null);
$serial->setWarrantyEndDate($serialData['warrantyEndDate'] ?? null);
$serial->setStatus($serialData['status'] ?? 'active');
$serial->setNotes($serialData['notes'] ?? null);
$entityManager->persist($serial);
$successCount++;
} catch (\Exception $e) {
$errors[] = "ردیف " . ($index + 1) . ": " . $e->getMessage();
$errorCount++;
}
}
$entityManager->flush();
$log->insert(
'گارانتی',
'وارد کردن انبوه سریال‌های گارانتی: ' . $successCount . ' موفق، ' . $errorCount . ' ناموفق',
$this->getUser(),
$acc['bid']
);
return $this->json([
'success' => true,
'message' => 'عملیات وارد کردن انبوه تکمیل شد',
'successCount' => $successCount,
'errorCount' => $errorCount,
'errors' => $errors
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/stats', name: 'plugin_warranty_stats', methods: ['GET'])]
public function plugin_warranty_stats(EntityManagerInterface $entityManager, Access $access): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$this->updateExpiredSerials($entityManager, $acc['bid']);
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
$allSerials = $repository->createQueryBuilder('p')
->andWhere('p.bid = :bid')
->setParameter('bid', $acc['bid'])
->getQuery()
->getResult();
$totalSerials = count($allSerials);
$activeSerials = 0;
$inactiveSerials = 0;
$expiredSerials = 0;
foreach ($allSerials as $serial) {
$status = $serial->getStatus();
switch ($status) {
case 'active':
$activeSerials++;
break;
case 'inactive':
$inactiveSerials++;
break;
case 'expired':
$expiredSerials++;
break;
}
}
return $this->json([
'totalSerials' => $totalSerials,
'activeSerials' => $activeSerials,
'inactiveSerials' => $inactiveSerials,
'expiredSerials' => $expiredSerials,
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
#[Route('/api/plugins/warranty/serials/update-expired', name: 'plugin_warranty_update_expired', methods: ['POST'])]
public function plugin_warranty_update_expired(EntityManagerInterface $entityManager, Access $access): JsonResponse
{
try {
$acc = $access->hasRole('plugWarrantyManager');
if(!$acc)
throw $this->createAccessDeniedException();
$repository = $entityManager->getRepository(PlugWarrantySerial::class);
$jdate = new Jdate();
$today = $jdate->GetTodayDate();
$expiredSerials = $repository->createQueryBuilder('s')
->where('s.bid = :businessId')
->andWhere('s.status = :status')
->andWhere('s.warrantyEndDate IS NOT NULL')
->andWhere('s.warrantyEndDate < :today')
->setParameter('businessId', $acc['bid'])
->setParameter('status', 'active')
->setParameter('today', $today)
->getQuery()
->getResult();
$updatedCount = 0;
foreach ($expiredSerials as $serial) {
$serial->setStatus('expired');
$updatedCount++;
}
if ($updatedCount > 0) {
$entityManager->flush();
}
return $this->json([
'success' => true,
'message' => 'به‌روزرسانی وضعیت سریال‌های منقضی شده تکمیل شد',
'updatedCount' => $updatedCount
]);
} catch (\Exception $e) {
return $this->json([
'error' => $e->getMessage()
], 500);
}
}
}

View file

@ -1187,14 +1187,14 @@ class TaxSettingsController extends AbstractController
$buyerEconomicCode = null; $buyerEconomicCode = null;
} }
if (empty($buyerPostalCode) || trim($buyerPostalCode) === '' || count_chars($buyerPostalCode) != 10) { if (empty($buyerPostalCode) || trim($buyerPostalCode) === '') {
$buyerPostalCode = null; $buyerPostalCode = null;
} }
} }
$personType = 1; $personType = 1;
if (count_chars($buyerNationalId ) == 11) { if (strlen($buyerNationalId) == 11) {
$personType = 2; $personType = 2;
} }

View file

@ -303,6 +303,10 @@ class Business
#[ORM\OneToMany(mappedBy: 'business', targetEntity: AIConversation::class, orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'business', targetEntity: AIConversation::class, orphanRemoval: true)]
private Collection $aiConversations; private Collection $aiConversations;
#[ORM\OneToMany(mappedBy: 'bid', targetEntity: PlugWarrantySerial::class, orphanRemoval: true)]
#[Ignore]
private Collection $plugWarrantySerials;
public function __construct() public function __construct()
{ {
$this->logs = new ArrayCollection(); $this->logs = new ArrayCollection();
@ -347,6 +351,7 @@ class Business
$this->PlugGhestaDocs = new ArrayCollection(); $this->PlugGhestaDocs = new ArrayCollection();
$this->plugHrmDocs = new ArrayCollection(); $this->plugHrmDocs = new ArrayCollection();
$this->aiConversations = new ArrayCollection(); $this->aiConversations = new ArrayCollection();
$this->plugWarrantySerials = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -2120,4 +2125,34 @@ class Business
return $this; return $this;
} }
/**
* @return Collection<int, PlugWarrantySerial>
*/
public function getPlugWarrantySerials(): Collection
{
return $this->plugWarrantySerials;
}
public function addPlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if (!$this->plugWarrantySerials->contains($plugWarrantySerial)) {
$this->plugWarrantySerials->add($plugWarrantySerial);
$plugWarrantySerial->setBid($this);
}
return $this;
}
public function removePlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if ($this->plugWarrantySerials->removeElement($plugWarrantySerial)) {
// set the owning side to null (unless already changed)
if ($plugWarrantySerial->getBid() === $this) {
$plugWarrantySerial->setBid(null);
}
}
return $this;
}
} }

View file

@ -80,6 +80,10 @@ class Commodity
#[ORM\OneToMany(mappedBy: 'commodity', targetEntity: PlugRepserviceOrder::class, orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'commodity', targetEntity: PlugRepserviceOrder::class, orphanRemoval: true)]
private Collection $plugRepserviceOrders; private Collection $plugRepserviceOrders;
#[ORM\OneToMany(mappedBy: 'commodity', targetEntity: PlugWarrantySerial::class, orphanRemoval: true)]
#[Ignore]
private Collection $plugWarrantySerials;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?bool $withoutTax = null; private ?bool $withoutTax = null;
@ -115,6 +119,7 @@ class Commodity
$this->commodityDropLinks = new ArrayCollection(); $this->commodityDropLinks = new ArrayCollection();
$this->storeroomItems = new ArrayCollection(); $this->storeroomItems = new ArrayCollection();
$this->plugRepserviceOrders = new ArrayCollection(); $this->plugRepserviceOrders = new ArrayCollection();
$this->plugWarrantySerials = new ArrayCollection();
$this->priceListDetails = new ArrayCollection(); $this->priceListDetails = new ArrayCollection();
$this->preInvoiceItems = new ArrayCollection(); $this->preInvoiceItems = new ArrayCollection();
} }
@ -424,6 +429,36 @@ class Commodity
return $this; return $this;
} }
/**
* @return Collection<int, PlugWarrantySerial>
*/
public function getPlugWarrantySerials(): Collection
{
return $this->plugWarrantySerials;
}
public function addPlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if (!$this->plugWarrantySerials->contains($plugWarrantySerial)) {
$this->plugWarrantySerials->add($plugWarrantySerial);
$plugWarrantySerial->setCommodity($this);
}
return $this;
}
public function removePlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if ($this->plugWarrantySerials->removeElement($plugWarrantySerial)) {
// set the owning side to null (unless already changed)
if ($plugWarrantySerial->getCommodity() === $this) {
$plugWarrantySerial->setCommodity(null);
}
}
return $this;
}
public function isWithoutTax(): ?bool public function isWithoutTax(): ?bool
{ {
return $this->withoutTax; return $this->withoutTax;

View file

@ -129,6 +129,9 @@ class Permission
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?bool $plugGhestaManager = null; private ?bool $plugGhestaManager = null;
#[ORM\Column(nullable: true)]
private ?bool $plugWarrantyManager = null;
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?bool $plugTaxSettings = null; private ?bool $plugTaxSettings = null;
@ -599,6 +602,18 @@ class Permission
return $this; return $this;
} }
public function isPlugWarrantyManager(): ?bool
{
return $this->plugWarrantyManager;
}
public function setPlugWarrantyManager(?bool $plugWarrantyManager): static
{
$this->plugWarrantyManager = $plugWarrantyManager;
return $this;
}
public function isPlugTaxSettings(): ?bool public function isPlugTaxSettings(): ?bool
{ {
return $this->plugTaxSettings; return $this->plugTaxSettings;

View file

@ -0,0 +1,171 @@
<?php
namespace App\Entity;
use App\Repository\PlugWarrantySerialRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Ignore;
#[ORM\Entity(repositoryClass: PlugWarrantySerialRepository::class)]
class PlugWarrantySerial
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Business $bid = null;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[ORM\JoinColumn(nullable: false)]
#[Ignore]
private ?Commodity $commodity = null;
#[ORM\Column(length: 255, unique: true)]
private ?string $serialNumber = null;
#[ORM\Column(length: 25)]
private ?string $dateSubmit = null;
#[ORM\ManyToOne(inversedBy: 'plugWarrantySerials')]
#[Ignore]
private ?User $submitter = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 25, nullable: true)]
private ?string $warrantyStartDate = null;
#[ORM\Column(length: 25, nullable: true)]
private ?string $warrantyEndDate = null;
#[ORM\Column(length: 50, nullable: true)]
private ?string $status = 'active';
#[ORM\Column(length: 255, nullable: true)]
private ?string $notes = null;
public function __construct()
{
$this->dateSubmit = date('Y-m-d H:i:s');
}
public function getId(): ?int
{
return $this->id;
}
public function getBid(): ?Business
{
return $this->bid;
}
public function setBid(?Business $bid): static
{
$this->bid = $bid;
return $this;
}
public function getCommodity(): ?Commodity
{
return $this->commodity;
}
public function setCommodity(?Commodity $commodity): static
{
$this->commodity = $commodity;
return $this;
}
public function getSerialNumber(): ?string
{
return $this->serialNumber;
}
public function setSerialNumber(string $serialNumber): static
{
$this->serialNumber = $serialNumber;
return $this;
}
public function getDateSubmit(): ?string
{
return $this->dateSubmit;
}
public function setDateSubmit(string $dateSubmit): static
{
$this->dateSubmit = $dateSubmit;
return $this;
}
public function getSubmitter(): ?User
{
return $this->submitter;
}
public function setSubmitter(?User $submitter): static
{
$this->submitter = $submitter;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getWarrantyStartDate(): ?string
{
return $this->warrantyStartDate;
}
public function setWarrantyStartDate(?string $warrantyStartDate): static
{
$this->warrantyStartDate = $warrantyStartDate;
return $this;
}
public function getWarrantyEndDate(): ?string
{
return $this->warrantyEndDate;
}
public function setWarrantyEndDate(?string $warrantyEndDate): static
{
$this->warrantyEndDate = $warrantyEndDate;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
return $this;
}
}

View file

@ -98,6 +98,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(mappedBy: 'submitter', targetEntity: PlugRepserviceOrder::class, orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'submitter', targetEntity: PlugRepserviceOrder::class, orphanRemoval: true)]
private Collection $plugRepserviceOrders; private Collection $plugRepserviceOrders;
#[ORM\OneToMany(mappedBy: 'submitter', targetEntity: PlugWarrantySerial::class, orphanRemoval: true)]
#[Ignore]
private Collection $plugWarrantySerials;
#[ORM\OneToMany(mappedBy: 'submitter', targetEntity: Note::class, orphanRemoval: true)] #[ORM\OneToMany(mappedBy: 'submitter', targetEntity: Note::class, orphanRemoval: true)]
private Collection $notes; private Collection $notes;
@ -159,6 +163,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->cheques = new ArrayCollection(); $this->cheques = new ArrayCollection();
$this->mostDes = new ArrayCollection(); $this->mostDes = new ArrayCollection();
$this->plugRepserviceOrders = new ArrayCollection(); $this->plugRepserviceOrders = new ArrayCollection();
$this->plugWarrantySerials = new ArrayCollection();
$this->notes = new ArrayCollection(); $this->notes = new ArrayCollection();
$this->preInvoiceDocs = new ArrayCollection(); $this->preInvoiceDocs = new ArrayCollection();
$this->dashboardSettings = new ArrayCollection(); $this->dashboardSettings = new ArrayCollection();
@ -798,6 +803,36 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
/**
* @return Collection<int, PlugWarrantySerial>
*/
public function getPlugWarrantySerials(): Collection
{
return $this->plugWarrantySerials;
}
public function addPlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if (!$this->plugWarrantySerials->contains($plugWarrantySerial)) {
$this->plugWarrantySerials->add($plugWarrantySerial);
$plugWarrantySerial->setSubmitter($this);
}
return $this;
}
public function removePlugWarrantySerial(PlugWarrantySerial $plugWarrantySerial): static
{
if ($this->plugWarrantySerials->removeElement($plugWarrantySerial)) {
// set the owning side to null (unless already changed)
if ($plugWarrantySerial->getSubmitter() === $this) {
$plugWarrantySerial->setSubmitter(null);
}
}
return $this;
}
/** /**
* @return Collection<int, Note> * @return Collection<int, Note>
*/ */

View file

@ -0,0 +1,98 @@
<?php
namespace App\Repository;
use App\Entity\PlugWarrantySerial;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlugWarrantySerial>
*/
class PlugWarrantySerialRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PlugWarrantySerial::class);
}
/**
* @return PlugWarrantySerial[] Returns an array of PlugWarrantySerial objects
*/
public function findByBusiness($bid): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :val')
->setParameter('val', $bid)
->orderBy('p.dateSubmit', 'DESC')
->getQuery()
->getResult()
;
}
/**
* @return PlugWarrantySerial[] Returns an array of PlugWarrantySerial objects by commodity
*/
public function findByCommodity($bid, $commodityId): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :bid')
->andWhere('p.commodity = :commodityId')
->setParameter('bid', $bid)
->setParameter('commodityId', $commodityId)
->orderBy('p.dateSubmit', 'DESC')
->getQuery()
->getResult()
;
}
/**
* @return PlugWarrantySerial[] Returns an array of PlugWarrantySerial objects by status
*/
public function findByStatus($bid, $status): array
{
return $this->createQueryBuilder('p')
->andWhere('p.bid = :bid')
->andWhere('p.status = :status')
->setParameter('bid', $bid)
->setParameter('status', $status)
->orderBy('p.dateSubmit', 'DESC')
->getQuery()
->getResult()
;
}
/**
* Check if serial number exists
*/
public function isSerialNumberExists($serialNumber, $bid = null): bool
{
$qb = $this->createQueryBuilder('p')
->andWhere('p.serialNumber = :serialNumber')
->setParameter('serialNumber', $serialNumber);
if ($bid) {
$qb->andWhere('p.bid = :bid')
->setParameter('bid', $bid);
}
return $qb->getQuery()->getOneOrNullResult() !== null;
}
/**
* Search serials by keyword
*/
public function searchSerials($bid, $keyword): array
{
return $this->createQueryBuilder('p')
->leftJoin('p.commodity', 'c')
->andWhere('p.bid = :bid')
->andWhere('p.serialNumber LIKE :keyword OR c.name LIKE :keyword OR p.description LIKE :keyword')
->setParameter('bid', $bid)
->setParameter('keyword', '%' . $keyword . '%')
->orderBy('p.dateSubmit', 'DESC')
->getQuery()
->getResult()
;
}
}

View file

@ -0,0 +1,532 @@
<template>
<v-dialog v-model="dialog" max-width="800px" persistent>
<v-card>
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="success">mdi-file-excel</v-icon>
<span>وارد کردن انبوه سریالهای گارانتی</span>
</v-card-title>
<v-card-text>
<v-tabs v-model="activeTab" color="primary">
<v-tab value="manual">ورود دستی</v-tab>
<v-tab value="file">آپلود فایل</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<!-- Manual Entry -->
<v-window-item value="manual">
<div class="mt-4">
<div class="d-flex justify-space-between align-center mb-4">
<h3 class="text-h6">ورود دستی سریالها</h3>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="addManualRow"
size="small"
>
افزودن ردیف
</v-btn>
</div>
<div class="manual-entries">
<div
v-for="(item, index) in manualSerials"
:key="index"
class="entry-card mb-4"
>
<v-card variant="outlined" class="pa-4">
<div class="d-flex justify-space-between align-center mb-3">
<h4 class="text-subtitle-1">سریال {{ index + 1 }}</h4>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="removeManualRow(index)"
:disabled="manualSerials.length === 1"
></v-btn>
</div>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="item.serialNumber"
label="شماره سریال *"
density="compact"
variant="outlined"
:rules="[rules.serialNumber]"
hide-details="auto"
maxlength="50"
counter
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
v-model="item.commodity_id"
:items="commodities"
item-title="name"
item-value="id"
label="محصول *"
density="compact"
variant="outlined"
:rules="[rules.commodity]"
hide-details="auto"
:filter="customFilter"
clearable
return-object
@update:model-value="(selectedCommodity) => handleCommoditySelect(selectedCommodity, index)"
></v-autocomplete>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="item.warrantyStartDate"
label="شروع گارانتی"
:rules="[rules.date]"
/>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="item.warrantyEndDate"
label="پایان گارانتی"
:rules="[rules.date, (value: any) => rules.endDate(value, item.warrantyStartDate)]"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="item.status"
:items="statusOptions"
item-title="title"
item-value="value"
label="وضعیت"
density="compact"
variant="outlined"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="12">
<v-textarea
v-model="item.description"
label="توضیحات"
density="compact"
variant="outlined"
rows="2"
auto-grow
hide-details="auto"
></v-textarea>
</v-col>
</v-row>
</v-card>
</div>
</div>
<v-alert
v-if="manualSerials.length === 0"
type="info"
class="mt-4"
>
<div class="text-center">
<v-icon size="48" color="info" class="mb-2">mdi-information</v-icon>
<div class="text-subtitle-1">هیچ سریالی اضافه نشده است</div>
<div class="text-body-2">برای شروع، روی دکمه "افزودن ردیف" کلیک کنید</div>
</div>
</v-alert>
</div>
</v-window-item>
<!-- File Upload -->
<v-window-item value="file">
<div class="mt-4">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-1 mb-3">آپلود فایل Excel</div>
<v-file-input
v-model="uploadedFile"
label="انتخاب فایل"
accept=".xlsx,.xls"
prepend-icon="mdi-file-excel"
show-size
counter
@change="handleFileUpload"
></v-file-input>
<v-alert
v-if="filePreview.length > 0"
type="info"
class="mt-3"
>
<div class="text-subtitle-2 mb-2">پیشنمایش دادهها:</div>
<v-data-table
:headers="previewHeaders"
:items="filePreview"
:items-per-page="5"
class="elevation-1"
></v-data-table>
</v-alert>
<v-alert
v-if="fileErrors.length > 0"
type="warning"
class="mt-3"
>
<div class="text-subtitle-2 mb-2">خطاهای موجود:</div>
<ul>
<li v-for="error in fileErrors" :key="error">{{ error }}</li>
</ul>
</v-alert>
</v-card>
</div>
</v-window-item>
</v-window>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="close">انصراف</v-btn>
<v-btn
color="success"
@click="importData"
:loading="loading"
:disabled="!canImport"
>
وارد کردن
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import Swal from 'sweetalert2'
const props = defineProps<{
modelValue: boolean
commodities: any[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
import: [data: any]
close: []
}>()
const loading = ref(false)
const activeTab = ref('manual')
const uploadedFile = ref(null)
const filePreview = ref<any[]>([])
const fileErrors = ref<string[]>([])
const manualSerials = ref<any[]>([
{
serialNumber: '',
commodity_id: '',
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active'
}
])
const rules = {
required: (value: any) => !!value || 'این فیلد الزامی است',
serialNumber: (value: any) => {
if (!value) return 'شماره سریال الزامی است'
if (!/^[A-Za-z0-9]+$/.test(value)) {
return 'شماره سریال فقط می‌تواند شامل حروف انگلیسی و اعداد باشد'
}
if (value.length < 3) {
return 'شماره سریال باید حداقل ۳ کاراکتر باشد'
}
if (value.length > 50) {
return 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
}
return true
},
commodity: (value: any) => {
if (!value) return 'انتخاب محصول الزامی است'
return true
},
date: (value: any) => {
if (!value) return true
const date = new Date(value)
if (isNaN(date.getTime())) {
return 'تاریخ نامعتبر است'
}
return true
},
endDate: (value: any, startDate: any) => {
if (!value || !startDate) return true
const end = new Date(value)
const start = new Date(startDate)
if (end <= start) {
return 'تاریخ پایان باید بعد از تاریخ شروع باشد'
}
return true
}
}
const statusOptions = [
{ title: 'فعال', value: 'active' },
{ title: 'غیرفعال', value: 'inactive' },
{ title: 'منقضی شده', value: 'expired' },
// { title: 'استفاده شده', value: 'used' }
]
const tableHeaders = [
{ title: 'شماره سریال', key: 'serialNumber' },
{ title: 'محصول', key: 'commodity_id' },
{ title: 'توضیحات', key: 'description' },
{ title: 'شروع گارانتی', key: 'warrantyStartDate' },
{ title: 'پایان گارانتی', key: 'warrantyEndDate' },
{ title: 'وضعیت', key: 'status' },
{ title: 'عملیات', key: 'actions', sortable: false }
]
const previewHeaders = [
{ title: 'شماره سریال', key: 'serialNumber' },
{ title: 'محصول', key: 'commodity' },
{ title: 'توضیحات', key: 'description' },
{ title: 'شروع گارانتی', key: 'warrantyStartDate' },
{ title: 'پایان گارانتی', key: 'warrantyEndDate' },
{ title: 'وضعیت', key: 'status' }
]
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const canImport = computed(() => {
if (activeTab.value === 'manual') {
return manualSerials.value.length > 0 &&
manualSerials.value.some(s => {
if (!s.serialNumber || !/^[A-Za-z0-9]{3,50}$/.test(s.serialNumber)) {
return false
}
if (!s.commodity_id) {
return false
}
if (s.warrantyStartDate && s.warrantyEndDate) {
const start = new Date(s.warrantyStartDate)
const end = new Date(s.warrantyEndDate)
if (end <= start) {
return false
}
}
return true
})
} else {
return uploadedFile.value && !fileErrors.value.length
}
})
const addManualRow = () => {
manualSerials.value.push({
serialNumber: '',
commodity_id: '',
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active'
})
}
const removeManualRow = (index: number) => {
manualSerials.value.splice(index, 1)
}
const handleFileUpload = async (file: File) => {
if (!file) {
filePreview.value = []
fileErrors.value = []
return
}
try {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/plugins/warranty/serials/preview-import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
filePreview.value = response.data.preview || []
fileErrors.value = response.data.errors || []
} catch (error) {
fileErrors.value = ['خطا در خواندن فایل']
console.error(error)
}
}
const importData = async () => {
try {
loading.value = true
let data
if (activeTab.value === 'manual') {
const validSerials = manualSerials.value.filter(s => {
if (!s.serialNumber || !/^[A-Za-z0-9]{3,50}$/.test(s.serialNumber)) {
return false
}
if (!s.commodity_id) {
return false
}
if (s.warrantyStartDate && s.warrantyEndDate) {
const start = new Date(s.warrantyStartDate)
const end = new Date(s.warrantyEndDate)
if (end <= start) {
return false
}
}
return true
})
if (validSerials.length === 0) {
await Swal.fire({
text: 'هیچ سریال معتبری برای وارد کردن وجود ندارد. لطفاً فیلدهای الزامی را پر کنید.',
icon: 'error',
confirmButtonText: 'قبول'
})
return
}
data = validSerials
} else {
if (!uploadedFile.value) {
await Swal.fire({
text: 'فایل انتخاب نشده است',
icon: 'error',
confirmButtonText: 'قبول'
})
return
}
const formData = new FormData()
formData.append('file', uploadedFile.value)
const response = await axios.post('/api/plugins/warranty/serials/import-excel', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
data = response.data.serials || []
}
if (data.length === 0) {
await Swal.fire({
text: 'داده‌ای برای وارد کردن وجود ندارد',
icon: 'error',
confirmButtonText: 'قبول'
})
return
}
emit('import', { serials: data })
await Swal.fire({
text: `عملیات وارد کردن با موفقیت انجام شد. ${data.length} سریال وارد شد.`,
icon: 'success',
confirmButtonText: 'قبول'
})
} catch (error) {
await Swal.fire({
text: 'خطا در وارد کردن داده‌ها',
icon: 'error',
confirmButtonText: 'قبول'
})
console.error(error)
} finally {
loading.value = false
}
}
const close = () => {
emit('close')
}
const customFilter = (item: any, queryText: string) => {
const text = item.name.toLowerCase()
const searchText = queryText.toLowerCase()
return text.indexOf(searchText) > -1
}
const handleCommoditySelect = (selectedCommodity: any, index: number) => {
if (selectedCommodity && selectedCommodity.id) {
manualSerials.value[index].commodity_id = selectedCommodity.id
} else {
manualSerials.value[index].commodity_id = ''
}
}
const resetData = () => {
manualSerials.value = [{
serialNumber: '',
commodity_id: '',
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active'
} as any]
uploadedFile.value = null
filePreview.value = []
fileErrors.value = []
activeTab.value = 'manual'
}
watch(() => props.modelValue, (newVal) => {
if (!newVal) {
resetData()
}
})
</script>
<style scoped>
.v-dialog {
direction: rtl;
}
.manual-entries {
max-height: 400px;
overflow-y: auto;
}
.entry-card {
transition: all 0.3s ease;
}
.entry-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.entry-card .v-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.entry-card .v-card:hover {
border-color: #1976d2;
}
.manual-entries::-webkit-scrollbar {
width: 6px;
}
.manual-entries::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.manual-entries::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.manual-entries::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View file

@ -0,0 +1,288 @@
<template>
<v-dialog v-model="dialog" max-width="600px" persistent>
<v-card>
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="primary">mdi-shield-check</v-icon>
<span>{{ isEdit ? 'ویرایش سریال گارانتی' : 'افزودن سریال گارانتی جدید' }}</span>
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="valid">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="formData.serialNumber"
label="شماره سریال *"
:rules="[rules.serialNumber]"
required
:disabled="isEdit"
variant="outlined"
density="comfortable"
hide-details="auto"
maxlength="50"
counter
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-autocomplete
v-model="formData.commodity_id"
label="محصول *"
:items="commodities"
item-title="name"
item-value="id"
:rules="[rules.commodity]"
required
:filter="customFilter"
clearable
return-object
@update:model-value="handleCommoditySelect"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-autocomplete>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.warrantyStartDate"
label="تاریخ شروع گارانتی"
:rules="[rules.date]"
/>
</v-col>
<v-col cols="12" md="6">
<h-date-picker
v-model="formData.warrantyEndDate"
label="تاریخ پایان گارانتی"
:rules="[rules.date, (value: any) => rules.endDate(value, formData.warrantyStartDate)]"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.status"
label="وضعیت"
:items="statusOptions"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
></v-select>
</v-col>
<v-col cols="12">
<v-textarea
v-model="formData.description"
label="توضیحات"
rows="3"
auto-grow
variant="outlined"
density="comfortable"
hide-details="auto"
></v-textarea>
</v-col>
<!-- <v-col cols="12">
<v-textarea
v-model="formData.notes"
label="یادداشت‌ها"
rows="2"
auto-grow
></v-textarea>
</v-col> -->
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="close">انصراف</v-btn>
<v-btn
color="primary"
@click="save"
:loading="loading"
:disabled="!valid"
>
{{ isEdit ? 'ویرایش' : 'ذخیره' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps<{
modelValue: boolean
serial?: any
commodities: any[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
save: [data: any]
close: []
}>()
const loading = ref(false)
const valid = ref(false)
const form = ref()
const formData = ref({
serialNumber: '',
commodity_id: '',
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active',
notes: ''
})
const rules = {
required: (value: any) => !!value || 'این فیلد الزامی است',
serialNumber: (value: any) => {
if (!value) return 'شماره سریال الزامی است'
if (!/^[A-Za-z0-9]+$/.test(value)) {
return 'شماره سریال فقط می‌تواند شامل حروف انگلیسی و اعداد باشد'
}
if (value.length < 3) {
return 'شماره سریال باید حداقل ۳ کاراکتر باشد'
}
if (value.length > 50) {
return 'شماره سریال نمی‌تواند بیشتر از ۵۰ کاراکتر باشد'
}
return true
},
commodity: (value: any) => {
if (!value) return 'انتخاب محصول الزامی است'
return true
},
date: (value: any) => {
if (!value) return true
const date = new Date(value)
if (isNaN(date.getTime())) {
return 'تاریخ نامعتبر است'
}
return true
},
endDate: (value: any, startDate: any) => {
if (!value || !startDate) return true
const end = new Date(value)
const start = new Date(startDate)
if (end <= start) {
return 'تاریخ پایان باید بعد از تاریخ شروع باشد'
}
return true
}
}
const statusOptions = [
{ title: 'فعال', value: 'active' },
{ title: 'غیرفعال', value: 'inactive' },
{ title: 'منقضی شده', value: 'expired' },
// { title: 'استفاده شده', value: 'used' }
]
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.serial)
const save = async () => {
const isValid = await form.value.validate()
if (!isValid) {
console.log('فرم دارای خطا است')
return
}
if (!formData.value.serialNumber || !formData.value.commodity_id) {
console.log('فیلدهای الزامی پر نشده‌اند')
return
}
try {
loading.value = true
const data = { ...formData.value }
emit('save', data)
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
const close = () => {
clearValidationErrors()
emit('close')
}
const resetForm = () => {
formData.value = {
serialNumber: '',
commodity_id: '',
description: '',
warrantyStartDate: '',
warrantyEndDate: '',
status: 'active',
notes: ''
}
if (form.value) {
form.value.resetValidation()
}
}
const clearValidationErrors = () => {
if (form.value) {
form.value.resetValidation()
}
}
const loadSerialData = () => {
if (props.serial) {
formData.value = {
serialNumber: props.serial.serialNumber || '',
commodity_id: props.serial.commodity?.id || '',
description: props.serial.description || '',
warrantyStartDate: props.serial.warrantyStartDate || '',
warrantyEndDate: props.serial.warrantyEndDate || '',
status: props.serial.status || 'active',
notes: props.serial.notes || ''
}
} else {
resetForm()
}
}
const customFilter = (item: any, queryText: string) => {
const text = item.name.toLowerCase()
const searchText = queryText.toLowerCase()
return text.indexOf(searchText) > -1
}
const handleCommoditySelect = (selectedCommodity: any) => {
if (selectedCommodity && selectedCommodity.id) {
formData.value.commodity_id = selectedCommodity.id
} else {
formData.value.commodity_id = ''
}
}
watch(() => props.serial, () => {
nextTick(() => {
loadSerialData()
})
}, { immediate: true })
watch(() => props.modelValue, (newVal) => {
if (newVal) {
nextTick(() => {
loadSerialData()
})
}
})
</script>
<style scoped>
.v-dialog {
direction: rtl;
}
</style>

View file

@ -0,0 +1,183 @@
<template>
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="d-flex align-center p-3 gap-2">
<v-icon class="mr-3" color="primary">mdi-shield-check</v-icon>
<span>جزئیات سریال گارانتی</span>
<v-spacer></v-spacer>
<v-chip
:color="getStatusColor(serial?.status)"
size="small"
>
{{ getStatusText(serial?.status) }}
</v-chip>
</v-card-title>
<v-card-text v-if="serial">
<v-row>
<v-col cols="12" md="6">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">اطلاعات سریال</div>
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-icon color="primary">mdi-barcode</v-icon>
</template>
<v-list-item-title>شماره سریال</v-list-item-title>
<v-list-item-subtitle class="font-weight-bold">
{{ serial.serialNumber }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="primary">mdi-package-variant</v-icon>
</template>
<v-list-item-title>محصول</v-list-item-title>
<v-list-item-subtitle>
<div class="font-weight-bold">{{ serial.commodity?.name }}</div>
<div class="text-caption">کد: {{ serial.commodity?.code }}</div>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="primary">mdi-calendar</v-icon>
</template>
<v-list-item-title>تاریخ ثبت</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(serial.dateSubmit) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">اطلاعات گارانتی</div>
<v-list>
<v-list-item>
<template v-slot:prepend>
<v-icon color="success">mdi-calendar-start</v-icon>
</template>
<v-list-item-title>شروع گارانتی</v-list-item-title>
<v-list-item-subtitle>
{{ serial.warrantyStartDate ? formatDate(serial.warrantyStartDate) : 'تعیین نشده' }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="warning">mdi-calendar-end</v-icon>
</template>
<v-list-item-title>پایان گارانتی</v-list-item-title>
<v-list-item-subtitle>
{{ serial.warrantyEndDate ? formatDate(serial.warrantyEndDate) : 'تعیین نشده' }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon color="info">mdi-account</v-icon>
</template>
<v-list-item-title>ثبت کننده</v-list-item-title>
<v-list-item-subtitle>{{ serial.submitter?.name }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</v-col>
<v-col cols="12" v-if="serial.description">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">توضیحات</div>
<p class="text-body-1">{{ serial.description }}</p>
</v-card>
</v-col>
<v-col cols="12" v-if="serial.notes">
<v-card variant="outlined" class="pa-4">
<div class="text-subtitle-2 text-grey mb-2">یادداشتها</div>
<p class="text-body-1">{{ serial.notes }}</p>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="close">بستن</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import moment from 'jalali-moment';
import { computed } from 'vue'
const props = defineProps<{
modelValue: boolean
serial?: any
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
close: []
}>()
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const close = () => {
emit('close')
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'inactive': return 'grey'
case 'expired': return 'warning'
case 'used': return 'info'
default: return 'grey'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'active': return 'فعال'
case 'inactive': return 'غیرفعال'
case 'expired': return 'منقضی شده'
// case 'used': return 'استفاده شده'
default: return status
}
}
const formatDate = (date: string) => {
if (!date) return '-'
if (date.includes('/') && date.split('/')[0].length === 4) {
return date
}
try {
const dateObj = new Date(date)
if (isNaN(dateObj.getTime())) return '-'
const jMoment = moment(dateObj)
const persianYear = jMoment.jYear()
const persianMonth = jMoment.jMonth() + 1
const persianDay = jMoment.jDate()
return `${persianYear}/${persianMonth.toString().padStart(2, '0')}/${persianDay.toString().padStart(2, '0')}`
} catch (error) {
return date
}
}
</script>
<style scoped>
.v-dialog {
direction: rtl;
}
</style>

View file

@ -89,6 +89,8 @@ const en_lang = {
inquiry: "Inquiries", inquiry: "Inquiries",
hrm: 'HR & Payroll', hrm: 'HR & Payroll',
hrm_docs: 'Payroll Document', hrm_docs: 'Payroll Document',
warranty_system: 'Warranty System',
warranty_serials: 'Warranty Serials',
}, },
}; };
export default en_lang export default en_lang

View file

@ -197,6 +197,8 @@ const fa_lang = {
inquiry: "استعلامات", inquiry: "استعلامات",
hrm: 'منابع انسانی', hrm: 'منابع انسانی',
hrm_docs: 'سند حقوق', hrm_docs: 'سند حقوق',
warranty_system: 'مدیریت گارانتی',
warranty_serials: 'سریال‌های گارانتی',
buysellByPerson: "گزارش خرید و فروش های اشخاص", buysellByPerson: "گزارش خرید و فروش های اشخاص",
tax_system: "سامانه مودیان مالیاتی", tax_system: "سامانه مودیان مالیاتی",
tax_invoices: "صورتحساب‌ ها", tax_invoices: "صورتحساب‌ ها",

View file

@ -864,6 +864,16 @@ const router = createRouter({
component: () => component: () =>
import('../views/acc/plugins/onlinestore/intro.vue'), import('../views/acc/plugins/onlinestore/intro.vue'),
}, },
{
path: 'plugins/warranty',
name: 'plugin_warranty',
component: () =>
import('../views/acc/plugins/warranty/WarrantyPlugin.vue'),
meta: {
'title': 'مدیریت گارانتی',
'login': true
}
},
{ {
path: 'notifications/list', path: 'notifications/list',
name: 'notification_list', name: 'notification_list',

View file

@ -193,6 +193,7 @@ export default {
{ path: '/acc/plugin-center/invoice', key: '`', label: this.$t('drawer.plugins_invoices'), ctrl: true, shift: true, permission: () => this.permissions.owner }, { path: '/acc/plugin-center/invoice', key: '`', label: this.$t('drawer.plugins_invoices'), ctrl: true, shift: true, permission: () => this.permissions.owner },
{ path: '/acc/hrm/docs/list', key: 'H', label: this.$t('drawer.hrm_docs'), ctrl: true, shift: true, permission: () => this.isPluginActive('hrm') && this.permissions.plugHrmDocs }, { path: '/acc/hrm/docs/list', key: 'H', label: this.$t('drawer.hrm_docs'), ctrl: true, shift: true, permission: () => this.isPluginActive('hrm') && this.permissions.plugHrmDocs },
{ path: '/acc/plugins/ghesta/list', key: 'G', label: this.$t('drawer.ghesta_invoices'), ctrl: true, shift: true, permission: () => this.isPluginActive('ghesta') && this.permissions.plugGhestaManager }, { path: '/acc/plugins/ghesta/list', key: 'G', label: this.$t('drawer.ghesta_invoices'), ctrl: true, shift: true, permission: () => this.isPluginActive('ghesta') && this.permissions.plugGhestaManager },
{ path: '/acc/plugins/warranty', key: 'W', label: this.$t('drawer.warranty_serials'), ctrl: true, shift: true, permission: () => this.isPluginActive('warranty') && this.permissions.plugWarranty },
{ path: '/acc/plugins/tax/invoices/list', key: 'L', label: this.$t('drawer.tax_invoices'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') }, { path: '/acc/plugins/tax/invoices/list', key: 'L', label: this.$t('drawer.tax_invoices'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') },
{ path: '/acc/plugins/tax/settings', key: 'T', label: this.$t('drawer.tax_settings'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') }, { path: '/acc/plugins/tax/settings', key: 'T', label: this.$t('drawer.tax_settings'), ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('taxsettings') },
]; ];
@ -859,6 +860,26 @@ export default {
</template> </template>
</v-list-item> </v-list-item>
</v-list-group> </v-list-group>
<v-list-group v-show="isPluginActive('warranty') && permissions.plugWarranty">
<template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" :title="$t('drawer.warranty_system')">
<template v-slot:prepend><v-icon icon="mdi-shield-check" color="primary"></v-icon></template>
</v-list-item>
</template>
<v-list-item to="/acc/plugins/warranty">
<v-list-item-title>
{{ $t('drawer.warranty_serials') }}
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/plugins/warranty') }}</span>
</v-list-item-title>
<template v-slot:append>
<v-tooltip :text="$t('dialog.add_new')" location="end">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-plus-box" variant="plain" />
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-group v-show="isPluginActive('taxsettings') && permissions.plugTaxSettings"> <v-list-group v-show="isPluginActive('taxsettings') && permissions.plugTaxSettings">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-list-item class="text-dark" v-bind="props" :title="$t('drawer.tax_system')"> <v-list-item class="text-dark" v-bind="props" :title="$t('drawer.tax_system')">

View file

@ -0,0 +1,828 @@
<template>
<div class="warranty-plugin">
<v-container fluid>
<!-- Stats Cards -->
<v-row>
<v-col cols="6" sm="6" md="3">
<div class="stats-card total-card">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-barcode-scan</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-barcode-scan</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">{{ stats.totalSerials || 0 }}</div>
<div class="stats-label">کل سریالها</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('active')" @click="filterByStatus('active')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-check-circle</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-check-circle</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.activeSerials || 0 }}
</div>
<div class="stats-label">سریالهای فعال</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('inactive')" @click="filterByStatus('inactive')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-pause-circle</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-pause-circle</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.inactiveSerials || 0 }}
</div>
<div class="stats-label">سریالهای غیرفعال</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="6" md="3">
<div :class="getCardClasses('expired')" @click="filterByStatus('expired')" role="button" tabindex="0">
<div class="stats-icon">
<v-icon size="24" color="white" class="d-sm-none">mdi-clock-alert</v-icon>
<v-icon size="32" color="white" class="d-none d-sm-block">mdi-clock-alert</v-icon>
</div>
<div class="stats-content">
<div class="stats-number">
<v-progress-circular
v-if="statsLoading"
indeterminate
size="20"
color="white"
class="me-2"
></v-progress-circular>
{{ stats.expiredSerials || 0 }}
</div>
<div class="stats-label">سریالهای منقضی شده</div>
</div>
</div>
</v-col>
</v-row>
<!-- Data Table -->
<v-row>
<v-col cols="12">
<v-card>
<!-- <v-card-title>
لیست سریالهای گارانتی
<v-spacer></v-spacer>
</v-card-title> -->
<v-data-table
:headers="headers"
:items="serials"
:loading="loading"
density="comfortable"
class="elevation-1"
:header-props="{ class: 'custom-header' }"
hover
>
<template v-slot:top>
<!-- موبایل -->
<div class="d-block d-md-none pa-4">
<div class="d-flex gap-2 flex-column mb-3">
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
size="small"
block
>
افزودن سریال
</v-btn>
<v-btn
color="secondary"
prepend-icon="mdi-upload"
@click="showBulkImportDialog = true"
size="small"
block
>
وارد کردن انبوه
</v-btn>
</div>
<v-text-field
v-model="filters.search"
label="جستجو"
prepend-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
class="mb-3"
@update:model-value="loadSerials"
/>
<div class="d-flex gap-2 mb-3">
<v-select
v-model="filters.status"
label="وضعیت"
:items="statusOptions"
clearable
density="compact"
variant="outlined"
hide-details
style="flex: 1;"
@update:model-value="loadSerials"
/>
<v-select
v-model="filters.commodity_id"
label="محصول"
:items="commodities"
item-title="name"
item-value="id"
clearable
density="compact"
variant="outlined"
hide-details
style="flex: 1;"
@update:model-value="loadSerials"
/>
</div>
</div>
<!-- دسکتاپ -->
<div class="d-none d-md-block">
<v-toolbar flat style="height: 70px !important; padding: 10px !important;">
<v-text-field
v-model="filters.search"
label="جستجو"
prepend-icon="mdi-magnify"
clearable
density="compact"
variant="outlined"
hide-details
style="max-width: 250px;"
@update:model-value="loadSerials"
class="ml-2"
/>
<v-select
v-model="filters.status"
label="وضعیت"
:items="statusOptions"
clearable
density="compact"
variant="outlined"
hide-details
style="max-width: 200px;"
@update:model-value="loadSerials"
class="ml-2"
/>
<v-select
v-model="filters.commodity_id"
label="محصول"
:items="commodities"
item-title="name"
item-value="id"
clearable
density="compact"
variant="outlined"
hide-details
style="max-width: 250px;"
@update:model-value="loadSerials"
class="ml-2"
/>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-plus"
@click="showAddDialog = true"
>
افزودن سریال
</v-btn>
<v-btn
color="secondary"
prepend-icon="mdi-upload"
@click="showBulkImportDialog = true"
class="ml-2"
>
وارد کردن انبوه
</v-btn>
</v-toolbar>
</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="getStatusColor((item as any).status)"
size="small"
>
{{ getStatusText((item as any).status) }}
</v-chip>
</template>
<template v-slot:item.dateSubmit="{ item }">
{{ formatDate((item as any).dateSubmit) }}
</template>
<template v-slot:item.warrantyStartDate="{ item }">
{{ (item as any).warrantyStartDate ? formatDate((item as any).warrantyStartDate) : '-' }}
</template>
<template v-slot:item.warrantyEndDate="{ item }">
{{ (item as any).warrantyEndDate ? formatDate((item as any).warrantyEndDate) : '-' }}
</template>
<template v-slot:item.commodity="{ item }">
<v-btn
variant="text"
color="primary"
size="small"
@click="viewCommodity((item as any).commodity)"
class="text-none"
>
{{ (item as any).commodity?.name || 'نامشخص' }}
</v-btn>
</template>
<template v-slot:item.actions="{ item }">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-menu"
variant="text"
size="small"
color="error"
></v-btn>
</template>
<v-list>
<v-list-item @click="viewSerial(item)">
<template v-slot:prepend>
<v-icon color="info">mdi-eye</v-icon>
</template>
<v-list-item-title>مشاهده سریال</v-list-item-title>
</v-list-item>
<v-list-item @click="editSerial(item)">
<template v-slot:prepend>
<v-icon color="warning">mdi-pencil</v-icon>
</template>
<v-list-item-title>ویرایش سریال</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteSerial(item)">
<template v-slot:prepend>
<v-icon color="error">mdi-delete</v-icon>
</template>
<v-list-item-title>حذف سریال</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-data-table>
</v-card>
</v-col>
</v-row>
</v-container>
<!-- Add/Edit Dialog -->
<SerialDialog
v-model="showAddDialog"
:serial="selectedSerial"
:commodities="commodities"
@save="saveSerial"
@close="closeDialog"
/>
<!-- View Dialog -->
<SerialViewDialog
v-model="showViewDialog"
:serial="selectedSerial"
@close="closeViewDialog"
/>
<!-- Bulk Import Dialog -->
<BulkImportDialog
v-model="showBulkImportDialog"
:commodities="commodities"
@import="bulkImport"
@close="closeBulkImportDialog"
/>
<!-- Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>تأیید حذف</v-card-title>
<v-card-text>
آیا از حذف سریال <strong>{{ selectedSerial?.serialNumber }}</strong> اطمینان دارید؟
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="showDeleteDialog = false">انصراف</v-btn>
<v-btn color="error" @click="confirmDelete" :loading="loading">حذف</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="showSnackbar"
:color="snackbarColor"
:timeout="3000"
location="bottom"
class="rounded-lg"
elevation="2"
>
<div class="d-flex align-center">
<v-icon :color="snackbarColor" class="me-2">
{{ snackbarColor === 'success' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ snackbarText }}
</div>
<template v-slot:actions>
<v-btn icon variant="text" @click="showSnackbar = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import SerialDialog from '@/components/plugins/warranty/SerialDialog.vue'
import SerialViewDialog from '@/components/plugins/warranty/SerialViewDialog.vue'
import BulkImportDialog from '@/components/plugins/warranty/BulkImportDialog.vue'
import axios from 'axios'
import moment from 'jalali-moment'
const router = useRouter()
const loading = ref(false)
const statsLoading = ref(false)
const serials = ref<any[]>([])
const commodities = ref<any[]>([])
const stats = ref({
totalSerials: 0,
activeSerials: 0,
expiredSerials: 0,
inactiveSerials: 0
})
const showSnackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const showNotification = (text: string, color: string = 'success') => {
snackbarText.value = text
snackbarColor.value = color
showSnackbar.value = true
}
const showAddDialog = ref(false)
const showViewDialog = ref(false)
const showDeleteDialog = ref(false)
const showBulkImportDialog = ref(false)
const selectedSerial = ref<any>(null)
const filters = ref({
search: '',
status: '',
commodity_id: null as any,
page: undefined as number | undefined,
limit: undefined as number | undefined
})
const headers = [
{ title: 'عملیات', key: 'actions', sortable: false },
{ title: 'شماره سریال', key: 'serialNumber', sortable: true },
{ title: 'محصول', key: 'commodity', sortable: true },
{ title: 'وضعیت', key: 'status', sortable: true },
{ title: 'تاریخ ثبت', key: 'dateSubmit', sortable: true },
{ title: 'شروع گارانتی', key: 'warrantyStartDate', sortable: true },
{ title: 'پایان گارانتی', key: 'warrantyEndDate', sortable: true },
{ title: 'توضیحات', key: 'description' },
]
const statusOptions = [
{ title: 'فعال', value: 'active' },
{ title: 'غیرفعال', value: 'inactive' },
{ title: 'منقضی شده', value: 'expired' },
// { title: 'استفاده شده', value: 'used' }
]
const loadSerials = async () => {
try {
loading.value = true
const params = { ...filters.value }
const queryParams = new URLSearchParams()
if (params.search) queryParams.append('search', params.search)
if (params.status) queryParams.append('status', params.status)
if (params.commodity_id) queryParams.append('commodity_id', params.commodity_id)
if (params.page) queryParams.append('page', params.page.toString())
if (params.limit) queryParams.append('limit', params.limit.toString())
const response = await axios.get(`/api/plugins/warranty/serials?${queryParams}`)
serials.value = Array.isArray(response.data) ? response.data : []
} catch (error: any) {
if (error?.response?.data?.error) {
showNotification(error.response.data.error, 'error')
return
}
showNotification('خطا در بارگذاری سریال‌ها', 'error')
console.error(error)
serials.value = []
} finally {
loading.value = false
}
}
const loadStats = async () => {
try {
statsLoading.value = true
const response = await axios.get('/api/plugins/warranty/stats')
stats.value = response.data
} catch (error: any) {
console.error('خطا در بارگذاری آمار:', error)
const totalSerials = serials.value.length
const activeSerials = serials.value.filter(serial => serial.status === 'active').length
const inactiveSerials = serials.value.filter(serial => serial.status === 'inactive').length
const expiredSerials = serials.value.filter(serial => serial.status === 'expired').length
stats.value = {
totalSerials,
activeSerials,
inactiveSerials,
expiredSerials
}
} finally {
statsLoading.value = false
}
}
const loadCommodities = async () => {
try {
const response = await axios.get('/api/commodity/list')
commodities.value = response.data
} catch (error: any) {
if (error?.response?.data?.error) {
showNotification(error.response.data.error, 'error')
return
}
showNotification('خطا در بارگذاری محصولات', 'error')
console.error(error)
}
}
const saveSerial = async (serialData: any) => {
try {
loading.value = true
if (selectedSerial.value) {
await axios.post(`/api/plugins/warranty/serials/edit/${selectedSerial.value.id}`, serialData)
showNotification('سریال با موفقیت ویرایش شد')
} else {
await axios.post('/api/plugins/warranty/serials/add', serialData)
showNotification('سریال با موفقیت اضافه شد')
}
await loadSerials()
closeDialog()
} catch (error: any) {
if (error?.response?.data?.error) {
showNotification(error.response.data.error, 'error')
return
}
showNotification('خطا در ذخیره سریال', 'error')
console.error(error)
} finally {
loading.value = false
}
}
const viewSerial = (serial: any) => {
selectedSerial.value = serial
showViewDialog.value = true
}
const editSerial = (serial: any) => {
selectedSerial.value = serial
showAddDialog.value = true
}
const deleteSerial = (serial: any) => {
selectedSerial.value = serial
showDeleteDialog.value = true
}
const confirmDelete = async () => {
try {
loading.value = true
await axios.delete(`/api/plugins/warranty/serials/${selectedSerial.value.id}`)
showNotification('سریال با موفقیت حذف شد')
await loadSerials()
showDeleteDialog.value = false
} catch (error: any) {
showNotification('خطا در حذف سریال', 'error')
console.error(error)
} finally {
loading.value = false
}
}
const bulkImport = async (serialsData: any) => {
try {
loading.value = true
const response = await axios.post('/api/plugins/warranty/serials/bulk-import', serialsData)
showNotification(`عملیات وارد کردن انبوه تکمیل شد. ${response.data.successCount} موفق، ${response.data.errorCount} ناموفق`)
await loadSerials()
closeBulkImportDialog()
} catch (error: any) {
if (error?.response?.data?.error) {
showNotification(error.response.data.error, 'error')
return
}
showNotification('خطا در وارد کردن انبوه', 'error')
console.error(error)
} finally {
loading.value = false
}
}
const viewCommodity = (commodity: any) => {
if (commodity && commodity.id) {
router.push(`/acc/commodity/mod/${commodity.code}`)
}
}
const closeDialog = () => {
showAddDialog.value = false
selectedSerial.value = null
}
const closeViewDialog = () => {
showViewDialog.value = false
selectedSerial.value = null
}
const closeBulkImportDialog = () => {
showBulkImportDialog.value = false
}
const clearFilters = () => {
filters.value = {
search: '',
status: '',
commodity_id: null,
page: undefined,
limit: undefined
}
loadSerials()
}
const filterByStatus = async (status: string) => {
if (filters.value.status === status) {
filters.value.status = ''
} else {
filters.value.status = status
}
filters.value.search = ''
filters.value.commodity_id = null
await Promise.all([
loadSerials(),
loadStats()
])
const statusText = getStatusText(status)
if (filters.value.status === status) {
showNotification(`فیلتر بر اساس وضعیت "${statusText}" اعمال شد`)
} else {
showNotification('فیلتر وضعیت پاک شد')
}
}
const getCardClasses = (status: string) => {
const baseClasses = 'stats-card'
const statusClasses = {
'active': 'active-card',
'inactive': 'inactive-card',
'expired': 'expired-card'
}
const classes = [baseClasses, statusClasses[status as keyof typeof statusClasses]]
if (filters.value.status === status) {
classes.push('active-filter')
}
return classes.join(' ')
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'success'
case 'inactive':
return 'grey'
case 'expired':
return 'warning'
case 'used':
return 'info'
default:
return 'grey'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return 'فعال'
case 'inactive':
return 'غیرفعال'
case 'expired':
return 'منقضی شده'
// case 'used':
// return 'استفاده شده'
default:
return 'نامشخص'
}
}
const formatDate = (date: string) => {
if (!date) return '-'
if (date.includes('/') && date.split('/')[0].length === 4) {
return date
}
try {
const dateObj = new Date(date)
if (isNaN(dateObj.getTime())) return '-'
const jMoment = moment(dateObj)
const persianYear = jMoment.jYear()
const persianMonth = jMoment.jMonth() + 1
const persianDay = jMoment.jDate()
return `${persianYear}/${persianMonth.toString().padStart(2, '0')}/${persianDay.toString().padStart(2, '0')}`
} catch (error) {
return date
}
}
onMounted(async () => {
await Promise.all([
loadStats(),
loadSerials(),
loadCommodities()
])
})
</script>
<style scoped>
.warranty-plugin {
padding: 20px;
}
.v-data-table {
direction: rtl;
}
:deep(.v-data-table-header th) {
background-color: #f5f5f5 !important;
font-weight: bold !important;
color: #333 !important;
}
:deep(.v-data-table__wrapper table td) {
padding: 12px 16px !important;
border-bottom: 1px solid #e0e0e0 !important;
}
:deep(.v-data-table__wrapper table tr:hover) {
background-color: #f8f9fa !important;
}
:deep(.v-chip) {
font-weight: 500;
}
.custom-header {
background-color: #f5f5f5 !important;
}
.stats-card {
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
color: white;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
cursor: pointer;
min-height: 120px;
display: flex;
align-items: center;
gap: 16px;
user-select: none;
}
.stats-card:focus {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
.stats-card.active-filter {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
border: 2px solid rgba(255, 255, 255, 0.8);
}
.stats-card.active-filter::before {
background: linear-gradient(45deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 100%);
}
.stats-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
border-radius: 16px;
z-index: 1;
}
.stats-card:hover {
transform: translateY(-8px) scale(1.01);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
}
.stats-icon {
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px;
backdrop-filter: blur(10px);
}
.stats-content {
position: relative;
z-index: 2;
flex: 1;
}
.stats-number {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.stats-label {
font-size: 0.9rem;
font-weight: 500;
opacity: 0.9;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* Card Variants - Professional Colors */
.total-card {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
}
.active-card {
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
}
.expired-card {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
.inactive-card {
background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);
}
@media (max-width: 768px) {
.stats-card {
flex-direction: column;
align-items: center;
text-align: center;
}
}
</style>