Merge branch 'master' of https://source.hesabix.ir/morrning/hesabixCore
This commit is contained in:
commit
0fb64e8cfa
41
hesabixCore/migrations/Version20250826214359.php
Normal file
41
hesabixCore/migrations/Version20250826214359.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?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 Version20250826214359 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 import_workflow ADD CONSTRAINT FK_CC6A26EC40C1FEA7 FOREIGN KEY (year_id) REFERENCES year (id)
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE INDEX IDX_CC6A26EC40C1FEA7 ON import_workflow (year_id)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE import_workflow DROP FOREIGN KEY FK_CC6A26EC40C1FEA7
|
||||||
|
SQL);
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DROP INDEX IDX_CC6A26EC40C1FEA7 ON import_workflow
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -130,7 +130,10 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
'supplierName' => $workflow->getSupplierName(),
|
'supplierName' => $workflow->getSupplierName(),
|
||||||
'totalAmount' => $workflow->getComputedTotalAmount(),
|
'totalAmount' => $workflow->getComputedTotalAmount(),
|
||||||
'currency' => $workflow->getCurrency(),
|
'currency' => $workflow->getCurrency(),
|
||||||
'submitter' => $workflow->getSubmitter()->getFullName()
|
'submitter' => $workflow->getSubmitter()->getFullName(),
|
||||||
|
'totalPayments' => $this->calculateTotalPayments($workflow),
|
||||||
|
'totalPaymentsIRR' => $this->calculateTotalPaymentsIRR($workflow),
|
||||||
|
'exchangeRate' => $workflow->getExchangeRate()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,6 +259,7 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
$workflow->setCode($provider->getAccountingCode($request->headers->get('activeBid'), 'ImportWorkflow'));
|
$workflow->setCode($provider->getAccountingCode($request->headers->get('activeBid'), 'ImportWorkflow'));
|
||||||
$workflow->setTitle($data['title'] ?? '');
|
$workflow->setTitle($data['title'] ?? '');
|
||||||
$workflow->setBusiness($acc['bid']);
|
$workflow->setBusiness($acc['bid']);
|
||||||
|
$workflow->setYear($acc['year']);
|
||||||
$workflow->setSubmitter($user);
|
$workflow->setSubmitter($user);
|
||||||
$workflow->setDescription($data['description'] ?? '');
|
$workflow->setDescription($data['description'] ?? '');
|
||||||
$workflow->setSupplierName($data['supplierName'] ?? '');
|
$workflow->setSupplierName($data['supplierName'] ?? '');
|
||||||
|
|
@ -444,6 +448,9 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
'supplierEmail' => $workflow->getSupplierEmail(),
|
'supplierEmail' => $workflow->getSupplierEmail(),
|
||||||
'currency' => $workflow->getCurrency(),
|
'currency' => $workflow->getCurrency(),
|
||||||
'exchangeRate' => $workflow->getExchangeRate(),
|
'exchangeRate' => $workflow->getExchangeRate(),
|
||||||
|
'totalAmount' => $workflow->getComputedTotalAmount(),
|
||||||
|
'totalPayments' => $this->calculateTotalPayments($workflow),
|
||||||
|
'totalPaymentsIRR' => $this->calculateTotalPaymentsIRR($workflow),
|
||||||
'submitter' => $workflow->getSubmitter()->getFullName(),
|
'submitter' => $workflow->getSubmitter()->getFullName(),
|
||||||
'items' => [],
|
'items' => [],
|
||||||
'payments' => [],
|
'payments' => [],
|
||||||
|
|
@ -494,7 +501,8 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
'status' => $payment->getStatus(),
|
'status' => $payment->getStatus(),
|
||||||
'description' => $payment->getDescription(),
|
'description' => $payment->getDescription(),
|
||||||
'receiptNumber' => $payment->getReceiptNumber(),
|
'receiptNumber' => $payment->getReceiptNumber(),
|
||||||
'dateSubmit' => $payment->getDateSubmit()
|
'dateSubmit' => $payment->getDateSubmit(),
|
||||||
|
'paymentMode' => $payment->getPaymentMode()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1513,6 +1521,7 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
$p = new ImportWorkflowPayment();
|
$p = new ImportWorkflowPayment();
|
||||||
$p->setImportWorkflow($workflow);
|
$p->setImportWorkflow($workflow);
|
||||||
$p->setType($data['type'] ?? 'other');
|
$p->setType($data['type'] ?? 'other');
|
||||||
|
$p->setPaymentMode($data['paymentMode'] ?? 'foreign');
|
||||||
$p->setAmount(($data['amount'] ?? '0'));
|
$p->setAmount(($data['amount'] ?? '0'));
|
||||||
$p->setCurrency($data['currency'] ?? 'IRR');
|
$p->setCurrency($data['currency'] ?? 'IRR');
|
||||||
$p->setAmountIRR(isset($data['amountIRR']) && $data['amountIRR'] !== '' ? (string)$data['amountIRR'] : null);
|
$p->setAmountIRR(isset($data['amountIRR']) && $data['amountIRR'] !== '' ? (string)$data['amountIRR'] : null);
|
||||||
|
|
@ -1601,6 +1610,7 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
}
|
}
|
||||||
$data = json_decode($request->getContent() ?: '{}', true);
|
$data = json_decode($request->getContent() ?: '{}', true);
|
||||||
if (isset($data['type'])) $p->setType($data['type']);
|
if (isset($data['type'])) $p->setType($data['type']);
|
||||||
|
if (isset($data['paymentMode'])) $p->setPaymentMode($data['paymentMode']);
|
||||||
if (isset($data['amount'])) $p->setAmount((string)$data['amount']);
|
if (isset($data['amount'])) $p->setAmount((string)$data['amount']);
|
||||||
if (isset($data['currency'])) $p->setCurrency($data['currency']);
|
if (isset($data['currency'])) $p->setCurrency($data['currency']);
|
||||||
if (isset($data['amountIRR'])) $p->setAmountIRR($data['amountIRR'] !== '' ? (string)$data['amountIRR'] : null);
|
if (isset($data['amountIRR'])) $p->setAmountIRR($data['amountIRR'] !== '' ? (string)$data['amountIRR'] : null);
|
||||||
|
|
@ -2292,4 +2302,42 @@ class PlugImportWorkflowController extends AbstractController
|
||||||
|
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function calculateTotalPayments(ImportWorkflow $workflow): float
|
||||||
|
{
|
||||||
|
$total = 0.0;
|
||||||
|
$exchangeRate = (float) $workflow->getExchangeRate();
|
||||||
|
|
||||||
|
foreach ($workflow->getPayments() as $payment) {
|
||||||
|
$amount = (float) $payment->getAmount();
|
||||||
|
|
||||||
|
if ($payment->getPaymentMode() === 'foreign') {
|
||||||
|
$total += $amount;
|
||||||
|
} else {
|
||||||
|
if ($exchangeRate > 0) {
|
||||||
|
$total += $amount / $exchangeRate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateTotalPaymentsIRR(ImportWorkflow $workflow): float
|
||||||
|
{
|
||||||
|
$total = 0.0;
|
||||||
|
$exchangeRate = (float) $workflow->getExchangeRate();
|
||||||
|
|
||||||
|
foreach ($workflow->getPayments() as $payment) {
|
||||||
|
$amount = (float) $payment->getAmount();
|
||||||
|
|
||||||
|
if ($payment->getPaymentMode() === 'foreign') {
|
||||||
|
$total += $amount * $exchangeRate;
|
||||||
|
} else {
|
||||||
|
$total += $amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($total, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ class ImportWorkflow
|
||||||
#[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2, nullable: true)]
|
#[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2, nullable: true)]
|
||||||
private ?string $exchangeRate = null;
|
private ?string $exchangeRate = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'importWorkflows')]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
#[Ignore]
|
||||||
|
private ?Year $year = null;
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowItem::class, orphanRemoval: true)]
|
#[ORM\OneToMany(mappedBy: 'importWorkflow', targetEntity: ImportWorkflowItem::class, orphanRemoval: true)]
|
||||||
private Collection $items;
|
private Collection $items;
|
||||||
|
|
||||||
|
|
@ -266,6 +271,17 @@ class ImportWorkflow
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getYear(): ?Year
|
||||||
|
{
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setYear(?Year $year): static
|
||||||
|
{
|
||||||
|
$this->year = $year;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getItems(): Collection
|
public function getItems(): Collection
|
||||||
{
|
{
|
||||||
return $this->items;
|
return $this->items;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ class ImportWorkflowPayment
|
||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $type = null;
|
private ?string $type = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
private ?string $paymentMode = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2)]
|
#[ORM\Column(type: Types::DECIMAL, precision: 15, scale: 2)]
|
||||||
private ?string $amount = null;
|
private ?string $amount = null;
|
||||||
|
|
||||||
|
|
@ -63,6 +66,7 @@ class ImportWorkflowPayment
|
||||||
{
|
{
|
||||||
$this->dateSubmit = date('Y-m-d H:i:s');
|
$this->dateSubmit = date('Y-m-d H:i:s');
|
||||||
$this->status = 'pending';
|
$this->status = 'pending';
|
||||||
|
$this->paymentMode = 'foreign';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
|
|
@ -92,6 +96,17 @@ class ImportWorkflowPayment
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getPaymentMode(): ?string
|
||||||
|
{
|
||||||
|
return $this->paymentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPaymentMode(string $paymentMode): static
|
||||||
|
{
|
||||||
|
$this->paymentMode = $paymentMode;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getAmount(): ?string
|
public function getAmount(): ?string
|
||||||
{
|
{
|
||||||
return $this->amount;
|
return $this->amount;
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,17 @@ class Year
|
||||||
#[ORM\OneToMany(mappedBy: 'year', targetEntity: PreInvoiceDoc::class, orphanRemoval: true)]
|
#[ORM\OneToMany(mappedBy: 'year', targetEntity: PreInvoiceDoc::class, orphanRemoval: true)]
|
||||||
private Collection $preInvoiceDocs;
|
private Collection $preInvoiceDocs;
|
||||||
|
|
||||||
|
#[ORM\OneToMany(mappedBy: 'year', targetEntity: ImportWorkflow::class, orphanRemoval: true)]
|
||||||
|
#[Ignore]
|
||||||
|
private Collection $importWorkflows;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->hesabdariDocs = new ArrayCollection();
|
$this->hesabdariDocs = new ArrayCollection();
|
||||||
$this->hesabdariRows = new ArrayCollection();
|
$this->hesabdariRows = new ArrayCollection();
|
||||||
$this->storeroomTickets = new ArrayCollection();
|
$this->storeroomTickets = new ArrayCollection();
|
||||||
$this->preInvoiceDocs = new ArrayCollection();
|
$this->preInvoiceDocs = new ArrayCollection();
|
||||||
|
$this->importWorkflows = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
|
|
@ -265,4 +270,34 @@ class Year
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, ImportWorkflow>
|
||||||
|
*/
|
||||||
|
public function getImportWorkflows(): Collection
|
||||||
|
{
|
||||||
|
return $this->importWorkflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addImportWorkflow(ImportWorkflow $importWorkflow): static
|
||||||
|
{
|
||||||
|
if (!$this->importWorkflows->contains($importWorkflow)) {
|
||||||
|
$this->importWorkflows->add($importWorkflow);
|
||||||
|
$importWorkflow->setYear($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeImportWorkflow(ImportWorkflow $importWorkflow): static
|
||||||
|
{
|
||||||
|
if ($this->importWorkflows->removeElement($importWorkflow)) {
|
||||||
|
// set the owning side to null (unless already changed)
|
||||||
|
if ($importWorkflow->getYear() === $this) {
|
||||||
|
$importWorkflow->setYear(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,16 @@
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"apexcharts": "^4.6.0",
|
"apexcharts": "^4.6.0",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-jalali": "^3.2.0-0",
|
"date-fns-jalali": "^3.2.0-0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"downloadjs": "^1.4.7",
|
"downloadjs": "^1.4.7",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"jalali-moment": "^3.3.11",
|
"jalali-moment": "^3.3.11",
|
||||||
|
"jalaliday": "^3.1.0",
|
||||||
"libphonenumber-js": "^1.12.7",
|
"libphonenumber-js": "^1.12.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"marked": "^16.1.0",
|
"marked": "^16.1.0",
|
||||||
|
|
|
||||||
502
webUI/src/components/plugins/import-workflow/DashboardTab.vue
Normal file
502
webUI/src/components/plugins/import-workflow/DashboardTab.vue
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
<template>
|
||||||
|
<div class="dashboard-tab">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card total-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="primary" class="ml-3">mdi-import</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ summary.totalWorkflows }}</div>
|
||||||
|
<div class="text-caption">کل پروندهها</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card amount-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-currency-usd</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ formatMoney(summary.totalAmount) }}</div>
|
||||||
|
<div class="text-caption">مجموع مبالغ (ارزی)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card payment-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-cash-multiple</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ formatMoney(summary.totalPayments) }}</div>
|
||||||
|
<div class="text-caption">مجموع پرداختها (ارزی)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card remaining-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-account-cash</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6" :class="summary.remainingAmount < 0 ? 'text-error' : ''">
|
||||||
|
{{ formatMoney(summary.remainingAmount) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">باقیمانده (ارزی)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- <v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card completed-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-check-circle</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ summary.completedWorkflows }}</div>
|
||||||
|
<div class="text-caption">پروندههای تکمیل شده</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card progress-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-progress-clock</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ summary.inProgressWorkflows }}</div>
|
||||||
|
<div class="text-caption">پروندههای در جریان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card time-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-clock-outline</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ summary.averageCompletionTime }}</div>
|
||||||
|
<div class="text-caption">میانگین زمان تکمیل (روز)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="kpi-card growth-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" :color="summary.growthRate >= 0 ? 'success' : 'error'" class="ml-3">mdi-trending-up</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6" :class="summary.growthRate >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ summary.growthRate >= 0 ? '+' : '' }}{{ summary.growthRate }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">نرخ رشد ماهانه</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row> -->
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>توزیع پروندهها بر اساس تامینکنندگان</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="supplierChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-donut</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>توزیع مبالغ بر اساس واحد پول</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="currencyChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-pie</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>روند ماهانه مبالغ (ریالی)</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="trendChart" width="800" height="300"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-line</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<!-- <v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>Top 5 کشورهای مبدأ واردات</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="(country, index) in topCountries" :key="index">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar :color="getCountryColor(index)" size="32">
|
||||||
|
<span class="text-white font-weight-bold">{{ index + 1 }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ country.name || 'نامشخص' }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ country.count }} پرونده - {{ formatMoney(country.amount) }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col> -->
|
||||||
|
<!-- <v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>وضعیت پروندهها</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="statusChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-donut</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col> -->
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
workflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const supplierChart = ref(null)
|
||||||
|
const currencyChart = ref(null)
|
||||||
|
const trendChart = ref(null)
|
||||||
|
const statusChart = ref(null)
|
||||||
|
|
||||||
|
let supplierChartInstance = null
|
||||||
|
let currencyChartInstance = null
|
||||||
|
let trendChartInstance = null
|
||||||
|
let statusChartInstance = null
|
||||||
|
|
||||||
|
const topCountries = computed(() => {
|
||||||
|
const countries = {}
|
||||||
|
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const country = workflow.country || 'نامشخص'
|
||||||
|
if (!countries[country]) {
|
||||||
|
countries[country] = { name: country, count: 0, amount: 0 }
|
||||||
|
}
|
||||||
|
countries[country].count++
|
||||||
|
countries[country].amount += workflow.totalAmount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(countries)
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
.slice(0, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCountryColor = (index) => {
|
||||||
|
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF']
|
||||||
|
return colors[index] || '#C9CBCF'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPersianMonthName = (monthNumber) => {
|
||||||
|
const monthNames = {
|
||||||
|
'01': 'فروردین', '02': 'اردیبهشت', '03': 'خرداد', '04': 'تیر',
|
||||||
|
'05': 'مرداد', '06': 'شهریور', '07': 'مهر', '08': 'آبان',
|
||||||
|
'09': 'آذر', '10': 'دی', '11': 'بهمن', '12': 'اسفند',
|
||||||
|
'1': 'فروردین', '2': 'اردیبهشت', '3': 'خرداد', '4': 'تیر',
|
||||||
|
'5': 'مرداد', '6': 'شهریور', '7': 'مهر', '8': 'آبان',
|
||||||
|
'9': 'آذر', '10': 'دی', '11': 'بهمن', '12': 'اسفند'
|
||||||
|
}
|
||||||
|
return monthNames[monthNumber] || monthNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCharts = () => {
|
||||||
|
// Destroy existing charts before creating new ones
|
||||||
|
if (supplierChartInstance) {
|
||||||
|
supplierChartInstance.destroy()
|
||||||
|
supplierChartInstance = null
|
||||||
|
}
|
||||||
|
if (currencyChartInstance) {
|
||||||
|
currencyChartInstance.destroy()
|
||||||
|
currencyChartInstance = null
|
||||||
|
}
|
||||||
|
if (trendChartInstance) {
|
||||||
|
trendChartInstance.destroy()
|
||||||
|
trendChartInstance = null
|
||||||
|
}
|
||||||
|
if (statusChartInstance) {
|
||||||
|
statusChartInstance.destroy()
|
||||||
|
statusChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new charts
|
||||||
|
createSupplierChart()
|
||||||
|
createCurrencyChart()
|
||||||
|
createTrendChart()
|
||||||
|
createStatusChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSupplierChart = () => {
|
||||||
|
const ctx = supplierChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (supplierChartInstance) {
|
||||||
|
supplierChartInstance.destroy()
|
||||||
|
supplierChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplierData = {}
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const supplierName = workflow.supplierName || 'نامشخص'
|
||||||
|
supplierData[supplierName] = (supplierData[supplierName] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.keys(supplierData).length === 0) return
|
||||||
|
|
||||||
|
const sortedSuppliers = Object.entries(supplierData)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
supplierChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: sortedSuppliers.map(([name]) => name),
|
||||||
|
datasets: [{
|
||||||
|
data: sortedSuppliers.map(([, count]) => count),
|
||||||
|
backgroundColor: [
|
||||||
|
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||||||
|
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCurrencyChart = () => {
|
||||||
|
const ctx = currencyChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (currencyChartInstance) {
|
||||||
|
currencyChartInstance.destroy()
|
||||||
|
currencyChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyData = {}
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
currencyData[workflow.currency] = (currencyData[workflow.currency] || 0) + workflow.totalAmount
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.keys(currencyData).length === 0) return
|
||||||
|
|
||||||
|
currencyChartInstance = new Chart(ctx, {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(currencyData),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(currencyData),
|
||||||
|
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTrendChart = () => {
|
||||||
|
const ctx = trendChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (trendChartInstance) {
|
||||||
|
trendChartInstance.destroy()
|
||||||
|
trendChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyData = {}
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const persianYear = persianDate.year()
|
||||||
|
const persianMonth = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const monthKey = `${persianYear}-${persianMonth}`
|
||||||
|
monthlyData[monthKey] = (monthlyData[monthKey] || 0) + (workflow.totalAmount * workflow.exchangeRate)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedMonths = Object.keys(monthlyData).sort()
|
||||||
|
const labels = sortedMonths.map(month => {
|
||||||
|
const [year, monthNum] = month.split('-')
|
||||||
|
const monthName = getPersianMonthName(monthNum)
|
||||||
|
return `${monthName} ${year}`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sortedMonths.length === 0) return
|
||||||
|
|
||||||
|
trendChartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'مبلغ کل (ریالی)',
|
||||||
|
data: sortedMonths.map(month => monthlyData[month]),
|
||||||
|
borderColor: '#36A2EB',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStatusChart = () => {
|
||||||
|
const ctx = statusChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (statusChartInstance) {
|
||||||
|
statusChartInstance.destroy()
|
||||||
|
statusChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusData = {}
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const status = workflow.status || 'نامشخص'
|
||||||
|
statusData[status] = (statusData[status] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.keys(statusData).length === 0) return
|
||||||
|
|
||||||
|
statusChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(statusData),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(statusData),
|
||||||
|
backgroundColor: ['#4CAF50', '#2196F3', '#FF9800', '#F44336']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (supplierChartInstance) supplierChartInstance.destroy()
|
||||||
|
if (currencyChartInstance) currencyChartInstance.destroy()
|
||||||
|
if (trendChartInstance) trendChartInstance.destroy()
|
||||||
|
if (statusChartInstance) statusChartInstance.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.workflows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-card { border-left: 4px solid #1976d2; }
|
||||||
|
.amount-card { border-left: 4px solid #4caf50; }
|
||||||
|
.payment-card { border-left: 4px solid #2196f3; }
|
||||||
|
.remaining-card { border-left: 4px solid #ff9800; }
|
||||||
|
.completed-card { border-left: 4px solid #4caf50; }
|
||||||
|
.progress-card { border-left: 4px solid #2196f3; }
|
||||||
|
.time-card { border-left: 4px solid #ff9800; }
|
||||||
|
.growth-card { border-left: 4px solid #9c27b0; }
|
||||||
|
</style>
|
||||||
550
webUI/src/components/plugins/import-workflow/FinancialTab.vue
Normal file
550
webUI/src/components/plugins/import-workflow/FinancialTab.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
<template>
|
||||||
|
<div class="financial-tab">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="4">
|
||||||
|
<v-card class="financial-card total-payments-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-cash-multiple</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ formatMoney(totalPayments) }}</div>
|
||||||
|
<div class="text-caption">مجموع پرداختها</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="4">
|
||||||
|
<v-card class="financial-card remaining-debt-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-account-cash</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6" :class="remainingDebt < 0 ? 'text-error' : ''">
|
||||||
|
{{ formatMoney(remainingDebt) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption">باقیمانده بدهی</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="4">
|
||||||
|
<v-card class="financial-card exchange-rate-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-currency-usd</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ averageExchangeRate }}</div>
|
||||||
|
<div class="text-caption">میانگین نرخ ارز</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>تفکیک پرداختها بر اساس نوع</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="paymentTypeChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-pie</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>مقایسه پرداختها و ماندهها</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="paymentsVsRemainingChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>نمودار مبالغ واردات بر اساس ارز</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="stackedCurrencyChart" width="800" height="300"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar-stacked</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>گزارش بدهی تأمینکنندگان</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="supplierDebtHeaders" :items="supplierDebtAnalysis" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.remainingAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.remainingAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.paymentPercentage="{ item }">
|
||||||
|
<v-progress-linear :model-value="item.paymentPercentage" color="primary" height="20">
|
||||||
|
<template v-slot:default>
|
||||||
|
{{ item.paymentPercentage.toFixed(1) }}%
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<!-- <v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>گزارش اختلاف نرخ ارز</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="exchangeRateHeaders" :items="exchangeRateAnalysis" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.rateDifference="{ item }">
|
||||||
|
<div :class="item.rateDifference >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ item.rateDifference >= 0 ? '+' : '' }}{{ item.rateDifference.toFixed(2) }}%
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.financialImpact="{ item }">
|
||||||
|
<div :class="item.financialImpact >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ formatMoney(item.financialImpact) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col> -->
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>جدول تفصیلی مالی</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="financialHeaders" :items="financialDetails" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }" hover>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.totalPayments="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalPayments) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.remainingAmount="{ item }">
|
||||||
|
<div :class="item.remainingAmount < 0 ? 'text-error' : ''">
|
||||||
|
{{ formatMoney(item.remainingAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.paymentPercentage="{ item }">
|
||||||
|
<v-progress-linear :model-value="item.paymentPercentage" color="primary" height="20">
|
||||||
|
<template v-slot:default>
|
||||||
|
{{ item.paymentPercentage.toFixed(1) }}%
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
workflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const paymentTypeChart = ref(null)
|
||||||
|
const paymentsVsRemainingChart = ref(null)
|
||||||
|
const stackedCurrencyChart = ref(null)
|
||||||
|
|
||||||
|
let paymentTypeChartInstance = null
|
||||||
|
let paymentsVsRemainingChartInstance = null
|
||||||
|
let stackedCurrencyChartInstance = null
|
||||||
|
|
||||||
|
const totalPayments = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
return props.workflows.reduce((sum, w) => sum + (w.totalPayments || 0), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingDebt = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const totalImportAmount = props.workflows.reduce((sum, w) => sum + (w.totalAmount || 0), 0)
|
||||||
|
const totalPayments = props.workflows.reduce((sum, w) => sum + (w.totalPayments || 0), 0)
|
||||||
|
|
||||||
|
return totalImportAmount - totalPayments
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageExchangeRate = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return '0'
|
||||||
|
const workflowsWithRate = props.workflows.filter(w => parseFloat(w.exchangeRate) > 0)
|
||||||
|
if (workflowsWithRate.length === 0) return '0'
|
||||||
|
|
||||||
|
const totalRate = workflowsWithRate.reduce((sum, w) => sum + parseFloat(w.exchangeRate), 0)
|
||||||
|
return (totalRate / workflowsWithRate.length).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const forecastAmount = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
const inProgressWorkflows = props.workflows.filter(w => w.status === 'in_progress')
|
||||||
|
return inProgressWorkflows.reduce((sum, w) => sum + (w.remainingAmount || 0), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const supplierDebtHeaders = [
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'مجموع تعهدات', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'پرداخت شده', key: 'totalPayments', sortable: true },
|
||||||
|
{ title: 'باقیمانده', key: 'remainingAmount', sortable: true },
|
||||||
|
// { title: 'درصد پرداخت', key: 'paymentPercentage', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const exchangeRateHeaders = [
|
||||||
|
{ title: 'واحد پول', key: 'currency', sortable: true },
|
||||||
|
{ title: 'نرخ ثبت', key: 'orderRate', sortable: true },
|
||||||
|
{ title: 'نرخ پرداخت', key: 'paymentRate', sortable: true },
|
||||||
|
{ title: 'اختلاف نرخ', key: 'rateDifference', sortable: true },
|
||||||
|
{ title: 'تأثیر مالی', key: 'financialImpact', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const financialHeaders = [
|
||||||
|
{ title: 'کد', key: 'code', sortable: true },
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'واحد پول', key: 'currency', sortable: true },
|
||||||
|
{ title: 'مجموع تعهدات', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'پرداخت شده', key: 'totalPayments', sortable: true },
|
||||||
|
{ title: 'باقیمانده', key: 'remainingAmount', sortable: true },
|
||||||
|
// { title: 'درصد پرداخت', key: 'paymentPercentage', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const supplierDebtAnalysis = computed(() => {
|
||||||
|
const suppliers = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const supplierName = workflow.supplierName || 'نامشخص'
|
||||||
|
if (!suppliers[supplierName]) {
|
||||||
|
suppliers[supplierName] = {
|
||||||
|
supplierName,
|
||||||
|
totalAmount: 0,
|
||||||
|
totalPayments: 0,
|
||||||
|
remainingAmount: 0,
|
||||||
|
currency: workflow.currency || 'نامشخص'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suppliers[supplierName].totalAmount += workflow.totalAmount || 0
|
||||||
|
suppliers[supplierName].totalPayments += workflow.totalPayments || 0
|
||||||
|
suppliers[supplierName].remainingAmount += workflow.remainingAmount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(suppliers).map(supplier => ({
|
||||||
|
...supplier,
|
||||||
|
paymentPercentage: supplier.totalAmount > 0 ? (supplier.totalPayments / supplier.totalAmount) * 100 : 0
|
||||||
|
})).sort((a, b) => b.remainingAmount - a.remainingAmount)
|
||||||
|
})
|
||||||
|
|
||||||
|
const exchangeRateAnalysis = computed(() => {
|
||||||
|
const currencies = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
if (workflow.currency && workflow.exchangeRate && workflow.paymentExchangeRate) {
|
||||||
|
if (!currencies[workflow.currency]) {
|
||||||
|
currencies[workflow.currency] = {
|
||||||
|
currency: workflow.currency,
|
||||||
|
orderRate: workflow.exchangeRate,
|
||||||
|
paymentRate: workflow.paymentExchangeRate,
|
||||||
|
rateDifference: ((workflow.paymentExchangeRate - workflow.exchangeRate) / workflow.exchangeRate) * 100,
|
||||||
|
financialImpact: (workflow.paymentExchangeRate - workflow.exchangeRate) * (workflow.totalPayments || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(currencies)
|
||||||
|
})
|
||||||
|
|
||||||
|
const financialDetails = computed(() => {
|
||||||
|
return props.workflows.map(workflow => ({
|
||||||
|
code: workflow.code,
|
||||||
|
supplierName: workflow.supplierName,
|
||||||
|
currency: workflow.currency,
|
||||||
|
totalAmount: workflow.totalAmount || 0,
|
||||||
|
totalPayments: workflow.totalPayments || 0,
|
||||||
|
remainingAmount: workflow.remainingAmount || 0,
|
||||||
|
paymentPercentage: workflow.totalAmount > 0 ? ((workflow.totalPayments || 0) / workflow.totalAmount) * 100 : 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCharts = () => {
|
||||||
|
// Destroy existing charts before creating new ones
|
||||||
|
if (paymentTypeChartInstance) {
|
||||||
|
paymentTypeChartInstance.destroy()
|
||||||
|
paymentTypeChartInstance = null
|
||||||
|
}
|
||||||
|
if (paymentsVsRemainingChartInstance) {
|
||||||
|
paymentsVsRemainingChartInstance.destroy()
|
||||||
|
paymentsVsRemainingChartInstance = null
|
||||||
|
}
|
||||||
|
if (stackedCurrencyChartInstance) {
|
||||||
|
stackedCurrencyChartInstance.destroy()
|
||||||
|
stackedCurrencyChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new charts
|
||||||
|
createPaymentTypeChart()
|
||||||
|
createPaymentsVsRemainingChart()
|
||||||
|
createStackedCurrencyChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPaymentTypeChart = () => {
|
||||||
|
const ctx = paymentTypeChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (paymentTypeChartInstance) {
|
||||||
|
paymentTypeChartInstance.destroy()
|
||||||
|
paymentTypeChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentTypes = {
|
||||||
|
'LC': 0,
|
||||||
|
'حواله': 0,
|
||||||
|
'نقدی': 0,
|
||||||
|
'سایر': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const paymentType = workflow.paymentType || 'سایر'
|
||||||
|
paymentTypes[paymentType] = (paymentTypes[paymentType] || 0) + (workflow.totalPayments || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
paymentTypeChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(paymentTypes),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(paymentTypes),
|
||||||
|
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPaymentsVsRemainingChart = () => {
|
||||||
|
const ctx = paymentsVsRemainingChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (paymentsVsRemainingChartInstance) {
|
||||||
|
paymentsVsRemainingChartInstance.destroy()
|
||||||
|
paymentsVsRemainingChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const currency = workflow.currency || 'نامشخص'
|
||||||
|
if (!currencies[currency]) {
|
||||||
|
currencies[currency] = { payments: 0, remaining: 0 }
|
||||||
|
}
|
||||||
|
currencies[currency].payments += workflow.totalPayments || 0
|
||||||
|
currencies[currency].remaining += workflow.remainingAmount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const labels = Object.keys(currencies)
|
||||||
|
const paymentsData = labels.map(currency => currencies[currency].payments)
|
||||||
|
const remainingData = labels.map(currency => currencies[currency].remaining)
|
||||||
|
|
||||||
|
paymentsVsRemainingChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'پرداخت شده',
|
||||||
|
data: paymentsData,
|
||||||
|
backgroundColor: '#4CAF50'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'باقیمانده',
|
||||||
|
data: remainingData,
|
||||||
|
backgroundColor: '#FF9800'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStackedCurrencyChart = () => {
|
||||||
|
const ctx = stackedCurrencyChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (stackedCurrencyChartInstance) {
|
||||||
|
stackedCurrencyChartInstance.destroy()
|
||||||
|
stackedCurrencyChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const persianYear = persianDate.year()
|
||||||
|
const persianMonth = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const monthKey = `${persianYear}-${persianMonth}`
|
||||||
|
|
||||||
|
if (!monthlyData[monthKey]) {
|
||||||
|
monthlyData[monthKey] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = workflow.currency || 'نامشخص'
|
||||||
|
monthlyData[monthKey][currency] = (monthlyData[monthKey][currency] || 0) + workflow.totalAmount
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedMonths = Object.keys(monthlyData).sort()
|
||||||
|
const currencies = [...new Set(props.workflows.map(w => w.currency).filter(Boolean))]
|
||||||
|
|
||||||
|
const datasets = currencies.map(currency => ({
|
||||||
|
label: currency,
|
||||||
|
data: sortedMonths.map(month => monthlyData[month][currency] || 0),
|
||||||
|
backgroundColor: getCurrencyColor(currency)
|
||||||
|
}))
|
||||||
|
|
||||||
|
stackedCurrencyChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: sortedMonths.map(month => {
|
||||||
|
const [year, monthNum] = month.split('-')
|
||||||
|
return `${year}/${monthNum}`
|
||||||
|
}),
|
||||||
|
datasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrencyColor = (currency) => {
|
||||||
|
const colors = {
|
||||||
|
'USD': '#FF6384',
|
||||||
|
'EUR': '#36A2EB',
|
||||||
|
'GBP': '#FFCE56',
|
||||||
|
'CNY': '#4BC0C0',
|
||||||
|
'AED': '#9966FF',
|
||||||
|
'IRR': '#FF9F40'
|
||||||
|
}
|
||||||
|
return colors[currency] || '#C9CBCF'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (paymentTypeChartInstance) paymentTypeChartInstance.destroy()
|
||||||
|
if (paymentsVsRemainingChartInstance) paymentsVsRemainingChartInstance.destroy()
|
||||||
|
if (stackedCurrencyChartInstance) stackedCurrencyChartInstance.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.workflows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.financial-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.financial-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-payments-card { border-left: 4px solid #4caf50; }
|
||||||
|
.remaining-debt-card { border-left: 4px solid #ff9800; }
|
||||||
|
.exchange-rate-card { border-left: 4px solid #2196f3; }
|
||||||
|
.forecast-card { border-left: 4px solid #9c27b0; }
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -3,12 +3,23 @@
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div class="d-flex justify-space-between align-center mb-4">
|
<div class="d-flex justify-space-between align-center mb-4">
|
||||||
<h3>پرداختها</h3>
|
<h3>پرداختها</h3>
|
||||||
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddDialog = true">
|
<div class="d-flex align-center gap-2">
|
||||||
افزودن پرداخت
|
<v-select
|
||||||
</v-btn>
|
v-model="paymentFilter"
|
||||||
|
:items="paymentFilterOptions"
|
||||||
|
label="فیلتر نوع پرداخت"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
style="min-width: 200px;"
|
||||||
|
clearable
|
||||||
|
></v-select>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="showAddDialog = true">
|
||||||
|
افزودن پرداخت
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-data-table :headers="headers" :items="payments" :loading="loading" density="comfortable" class="elevation-1"
|
<v-data-table :headers="headers" :items="filteredPayments" :loading="loading" density="comfortable" class="elevation-1"
|
||||||
:header-props="{ class: 'custom-header' }" no-data-text="پرداختی ثبت نشده است">
|
:header-props="{ class: 'custom-header' }" no-data-text="پرداختی ثبت نشده است">
|
||||||
<template v-slot:item.type="{ item }">
|
<template v-slot:item.type="{ item }">
|
||||||
<v-chip :color="getTypeColor(item.type)" size="small" variant="flat">
|
<v-chip :color="getTypeColor(item.type)" size="small" variant="flat">
|
||||||
|
|
@ -16,6 +27,12 @@
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:item.paymentMode="{ item }">
|
||||||
|
<v-chip :color="item.paymentMode === 'foreign' ? 'blue' : 'green'" size="small" variant="flat">
|
||||||
|
{{ item.paymentMode === 'foreign' ? 'ارزی' : 'ریالی' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-slot:item.amount="{ item }">
|
<template v-slot:item.amount="{ item }">
|
||||||
<div>
|
<div>
|
||||||
{{ formatNumber(item.amount) }}
|
{{ formatNumber(item.amount) }}
|
||||||
|
|
@ -25,8 +42,17 @@
|
||||||
|
|
||||||
<template v-slot:item.amountIRR="{ item }">
|
<template v-slot:item.amountIRR="{ item }">
|
||||||
<div>
|
<div>
|
||||||
{{ formatNumber(Number(item.amount) * Number(props.exchangeRate)) }}
|
<span v-if="!props.exchangeRate || props.exchangeRate <= 0">
|
||||||
<small class="text-medium-emphasis">ریال</small>
|
<small class="text-warning">نرخ ارز تنظیم نشده</small>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="item.paymentMode === 'foreign'">
|
||||||
|
{{ formatNumber(Number(item.amount) * Number(props.exchangeRate)) }}
|
||||||
|
<small class="text-medium-emphasis">ریال</small>
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ formatNumber(Number(item.amount) / Number(props.exchangeRate)) }}
|
||||||
|
<small class="text-medium-emphasis">{{ props.currency }}</small>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -59,11 +85,15 @@
|
||||||
<v-form ref="form" v-model="valid" @submit.prevent="savePayment">
|
<v-form ref="form" v-model="valid" @submit.prevent="savePayment">
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="4">
|
||||||
<v-select v-model="formData.type" :items="paymentTypes" label="نوع پرداخت" :rules="[rules.required]"
|
<v-select v-model="formData.type" :items="paymentTypes" label="نوع پرداخت" :rules="[rules.required]"
|
||||||
required></v-select>
|
required></v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="4">
|
||||||
|
<v-select v-model="formData.paymentMode" :items="paymentModeOptions" label="نوع ارز" :rules="[rules.required]"
|
||||||
|
required @update:model-value="onPaymentModeChange"></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
<v-select v-model="formData.status" :items="statusOptions" label="وضعیت" :rules="[rules.required]"
|
<v-select v-model="formData.status" :items="statusOptions" label="وضعیت" :rules="[rules.required]"
|
||||||
required></v-select>
|
required></v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
@ -71,20 +101,45 @@
|
||||||
|
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-text-field class="ltr-input" :model-value="formatMoneyTyping(formData.amount)" label="مبلغ (ارزی)"
|
<v-text-field
|
||||||
type="text" inputmode="decimal" :rules="[rules.required, rules.positiveMoney]" required
|
class="ltr-input"
|
||||||
|
:model-value="formatMoneyTyping(formData.amount)"
|
||||||
|
:label="getAmountLabel()"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
:rules="[rules.required, rules.positiveMoney]"
|
||||||
|
required
|
||||||
@update:modelValue="onMoneyInput('amount', $event)"
|
@update:modelValue="onMoneyInput('amount', $event)"
|
||||||
@blur="formData.amount = parseMoney(formData.amount)"></v-text-field>
|
@blur="formData.amount = parseMoney(formData.amount)">
|
||||||
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-text-field :model-value="formatMoney(computedAmountIRR)" label="مبلغ (ریال) - محاسباتی" readonly
|
<v-text-field
|
||||||
variant="outlined" color="primary"></v-text-field>
|
:model-value="!props.exchangeRate || props.exchangeRate <= 0 ? 'نرخ ارز تنظیم نشده' : formatMoney(computedAmountIRR)"
|
||||||
|
:label="getComputedAmountLabel()"
|
||||||
|
readonly
|
||||||
|
variant="outlined"
|
||||||
|
:color="!props.exchangeRate || props.exchangeRate <= 0 ? 'warning' : 'primary'">
|
||||||
|
</v-text-field>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-alert type="info" variant="tonal" class="mb-4">
|
<v-alert
|
||||||
<strong>نکته:</strong> مبلغ ریالی به صورت خودکار بر اساس نرخ ارز پرونده محاسبه میشود.
|
:type="!props.exchangeRate || props.exchangeRate <= 0 ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<strong>نکته:</strong>
|
||||||
|
<span v-if="!props.exchangeRate || props.exchangeRate <= 0">
|
||||||
|
نرخ ارز پرونده تنظیم نشده است. لطفاً ابتدا نرخ ارز را در تنظیمات پرونده وارد کنید.
|
||||||
|
</span>
|
||||||
|
<span v-else-if="formData.paymentMode === 'foreign'">
|
||||||
|
مبلغ ریالی به صورت خودکار بر اساس نرخ ارز پرونده محاسبه میشود.
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
مبلغ ارزی به صورت خودکار بر اساس نرخ ارز پرونده محاسبه میشود.
|
||||||
|
</span>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
@ -154,7 +209,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, reactive, watch } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import Swal from 'sweetalert2'
|
import Swal from 'sweetalert2'
|
||||||
import HDatepicker from '@/components/forms/Hdatepicker.vue'
|
import HDatepicker from '@/components/forms/Hdatepicker.vue'
|
||||||
|
|
@ -179,6 +234,13 @@ const props = defineProps({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredPayments = computed(() => {
|
||||||
|
if (!paymentFilter.value) {
|
||||||
|
return props.payments
|
||||||
|
}
|
||||||
|
return props.payments.filter(payment => payment.paymentMode === paymentFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['updated'])
|
const emit = defineEmits(['updated'])
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
|
|
@ -192,8 +254,9 @@ const valid = ref(false)
|
||||||
const saveLoading = ref(false)
|
const saveLoading = ref(false)
|
||||||
const deleteLoading = ref(false)
|
const deleteLoading = ref(false)
|
||||||
|
|
||||||
const formData = ref({
|
const formData = reactive({
|
||||||
type: '',
|
type: '',
|
||||||
|
paymentMode: 'foreign',
|
||||||
amount: '',
|
amount: '',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
amountIRR: '',
|
amountIRR: '',
|
||||||
|
|
@ -210,8 +273,9 @@ const formData = ref({
|
||||||
// Headers
|
// Headers
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'نوع پرداخت', key: 'type', sortable: false },
|
{ title: 'نوع پرداخت', key: 'type', sortable: false },
|
||||||
|
{ title: 'نوع ارز', key: 'paymentMode', sortable: false },
|
||||||
{ title: 'مبلغ', key: 'amount', sortable: false },
|
{ title: 'مبلغ', key: 'amount', sortable: false },
|
||||||
{ title: 'مبلغ (ریال)', key: 'amountIRR', sortable: false },
|
{ title: 'مبلغ محاسباتی', key: 'amountIRR', sortable: false },
|
||||||
{ title: 'تاریخ پرداخت', key: 'paymentDate', sortable: false },
|
{ title: 'تاریخ پرداخت', key: 'paymentDate', sortable: false },
|
||||||
{ title: 'دریافت کننده', key: 'recipientName', sortable: false },
|
{ title: 'دریافت کننده', key: 'recipientName', sortable: false },
|
||||||
{ title: 'وضعیت', key: 'status', sortable: false },
|
{ title: 'وضعیت', key: 'status', sortable: false },
|
||||||
|
|
@ -236,6 +300,19 @@ const statusOptions = [
|
||||||
{ title: 'لغو شده', value: 'cancelled' }
|
{ title: 'لغو شده', value: 'cancelled' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const paymentModeOptions = [
|
||||||
|
{ title: 'پرداخت ارزی', value: 'foreign' },
|
||||||
|
{ title: 'پرداخت ریالی', value: 'local' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const paymentFilterOptions = [
|
||||||
|
{ title: 'همه پرداختها', value: null },
|
||||||
|
{ title: 'فقط پرداختهای ارزی', value: 'foreign' },
|
||||||
|
{ title: 'فقط پرداختهای ریالی', value: 'local' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const paymentFilter = ref(null)
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ title: 'دلار آمریکا (USD)', value: 'USD' },
|
{ title: 'دلار آمریکا (USD)', value: 'USD' },
|
||||||
{ title: 'یورو (EUR)', value: 'EUR' },
|
{ title: 'یورو (EUR)', value: 'EUR' },
|
||||||
|
|
@ -276,7 +353,19 @@ const formatMoneyTyping = (val) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMoneyInput = (field, val) => {
|
const onMoneyInput = (field, val) => {
|
||||||
formData.value[field] = parseMoney(val)
|
formData[field] = parseMoney(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPaymentModeChange = (mode) => {
|
||||||
|
if (mode === 'foreign') {
|
||||||
|
formData.currency = props.currency || 'USD'
|
||||||
|
} else {
|
||||||
|
formData.currency = 'IRR'
|
||||||
|
}
|
||||||
|
// فقط در حالت افزودن جدید، مبلغ را پاک کن
|
||||||
|
if (!editingPayment.value) {
|
||||||
|
formData.amount = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatMoney = (value) => {
|
const formatMoney = (value) => {
|
||||||
|
|
@ -286,21 +375,43 @@ const formatMoney = (value) => {
|
||||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
}
|
}
|
||||||
|
|
||||||
// محاسبه مبلغ ریالی بر اساس نرخ ارز پرونده
|
|
||||||
const computedAmountIRR = computed(() => {
|
const computedAmountIRR = computed(() => {
|
||||||
if (!formData.value.amount || !props.exchangeRate) return 0
|
if (!formData.amount) return 0
|
||||||
const amount = parseFloat(formData.value.amount)
|
|
||||||
const exchangeRate = parseFloat(props.exchangeRate)
|
const amount = parseFloat(formData.amount)
|
||||||
const currency = props.currency
|
const exchangeRate = parseFloat(props.exchangeRate) || 0
|
||||||
|
|
||||||
|
if (exchangeRate <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
if (currency === 'IRR') return amount
|
if (formData.paymentMode === 'foreign') {
|
||||||
return Math.round(amount * exchangeRate)
|
// اگر پرداخت ارزی است، مبلغ ریالی محاسبه میشود
|
||||||
|
return (amount * exchangeRate).toFixed(2)
|
||||||
|
} else {
|
||||||
|
// اگر پرداخت ریالی است، مبلغ ارزی محاسبه میشود
|
||||||
|
return (amount / exchangeRate).toFixed(6)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
const editPayment = (payment) => {
|
const editPayment = (payment) => {
|
||||||
editingPayment.value = payment
|
editingPayment.value = payment
|
||||||
formData.value = { ...payment }
|
Object.assign(formData, {
|
||||||
|
type: payment.type || '',
|
||||||
|
paymentMode: payment.paymentMode || 'foreign',
|
||||||
|
amount: payment.amount || '',
|
||||||
|
currency: payment.currency || (payment.paymentMode === 'local' ? 'IRR' : 'USD'),
|
||||||
|
amountIRR: payment.amountIRR || '',
|
||||||
|
paymentDate: payment.paymentDate || '',
|
||||||
|
referenceNumber: payment.referenceNumber || '',
|
||||||
|
bankName: payment.bankName || '',
|
||||||
|
accountNumber: payment.accountNumber || '',
|
||||||
|
recipientName: payment.recipientName || '',
|
||||||
|
status: payment.status || 'pending',
|
||||||
|
description: payment.description || '',
|
||||||
|
receiptNumber: payment.receiptNumber || ''
|
||||||
|
})
|
||||||
showAddDialog.value = true
|
showAddDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,7 +434,7 @@ const savePayment = async () => {
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
data: formData.value
|
data: formData
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.data.Success) {
|
if (response.data.Success) {
|
||||||
|
|
@ -379,10 +490,11 @@ const confirmDelete = async () => {
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const cancelEdit = () => {
|
||||||
editingPayment.value = null
|
editingPayment.value = null
|
||||||
formData.value = {
|
Object.assign(formData, {
|
||||||
type: '',
|
type: '',
|
||||||
|
paymentMode: 'foreign',
|
||||||
amount: '',
|
amount: '',
|
||||||
currency: 'USD',
|
currency: props.currency || 'USD',
|
||||||
amountIRR: '',
|
amountIRR: '',
|
||||||
paymentDate: '',
|
paymentDate: '',
|
||||||
referenceNumber: '',
|
referenceNumber: '',
|
||||||
|
|
@ -392,7 +504,7 @@ const cancelEdit = () => {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
description: '',
|
description: '',
|
||||||
receiptNumber: ''
|
receiptNumber: ''
|
||||||
}
|
})
|
||||||
showAddDialog.value = false
|
showAddDialog.value = false
|
||||||
if (form.value) {
|
if (form.value) {
|
||||||
form.value.reset()
|
form.value.reset()
|
||||||
|
|
@ -461,10 +573,35 @@ const formatDate = (date) => {
|
||||||
return new Date(date).toLocaleDateString('fa-IR')
|
return new Date(date).toLocaleDateString('fa-IR')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAmountLabel = () => {
|
||||||
|
if (formData.paymentMode === 'foreign') {
|
||||||
|
return `مبلغ (${props.currency || 'USD'})`
|
||||||
|
} else {
|
||||||
|
return 'مبلغ (ریال)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getComputedAmountLabel = () => {
|
||||||
|
if (formData.paymentMode === 'foreign') {
|
||||||
|
return 'مبلغ (ریال) - محاسباتی'
|
||||||
|
} else {
|
||||||
|
return `مبلغ (${props.currency || 'USD'}) - محاسباتی`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
showAddDialog.value = false
|
showAddDialog.value = false
|
||||||
cancelEdit()
|
cancelEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch for payment mode changes to update currency
|
||||||
|
watch(() => formData.paymentMode, (newMode) => {
|
||||||
|
if (newMode === 'foreign') {
|
||||||
|
formData.currency = props.currency || 'USD'
|
||||||
|
} else {
|
||||||
|
formData.currency = 'IRR'
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
753
webUI/src/components/plugins/import-workflow/PerformanceTab.vue
Normal file
753
webUI/src/components/plugins/import-workflow/PerformanceTab.vue
Normal file
|
|
@ -0,0 +1,753 @@
|
||||||
|
<template>
|
||||||
|
<div class="performance-tab">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="performance-card average-time-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="primary" class="ml-3">mdi-clock-outline</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ averageCompletionTime }}</div>
|
||||||
|
<div class="text-caption">میانگین زمان تکمیل (روز)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="performance-card delayed-workflows-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-alert</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ delayedWorkflowsCount }}</div>
|
||||||
|
<div class="text-caption">پروندههای تأخیردار</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="performance-card efficiency-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-chart-line</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ efficiencyRate }}%</div>
|
||||||
|
<div class="text-caption">نرخ کارایی</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="performance-card seasonal-peak-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-calendar-month</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ peakSeason }}</div>
|
||||||
|
<div class="text-caption">فصل اوج فعالیت</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>میانگین زمان هر مرحله</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="stageTimeChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>توزیع تأخیرات بر اساس مدت</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="delayDistributionChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-pie</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>مقایسه سال به سال (YoY)</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="yearOverYearChart" width="800" height="300"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-line</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>تحلیل فصلی</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="seasonalAnalysisChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>Heatmap فعالیت روزانه</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="heatmap-container">
|
||||||
|
<div v-for="(day, dayIndex) in weekDays" :key="dayIndex" class="heatmap-row">
|
||||||
|
<div class="heatmap-day-label">{{ day }}</div>
|
||||||
|
<div v-for="(hour, hourIndex) in 24" :key="hourIndex"
|
||||||
|
class="heatmap-cell"
|
||||||
|
:style="{ backgroundColor: getHeatmapColor(dayIndex, hourIndex) }"
|
||||||
|
:title="`${day} ساعت ${hourIndex}: ${getHeatmapValue(dayIndex, hourIndex)} پرونده`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>پروندههای تأخیردار</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="delayHeaders" :items="delayedWorkflows" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.delayDays="{ item }">
|
||||||
|
<v-chip :color="getDelayColor(item.delayDays)" size="small">
|
||||||
|
{{ item.delayDays }} روز
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>عملکرد مراحل فرایند</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="stageHeaders" :items="stagePerformance" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.averageTime="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.averageTime }} روز
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.efficiency="{ item }">
|
||||||
|
<v-progress-linear :model-value="item.efficiency" color="primary" height="20">
|
||||||
|
<template v-slot:default>
|
||||||
|
{{ item.efficiency.toFixed(1) }}%
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>جدول تفصیلی عملکرد</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="performanceHeaders" :items="performanceDetails" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }" hover>
|
||||||
|
<template v-slot:item.completionTime="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.completionTime }} روز
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.status="{ item }">
|
||||||
|
<v-chip :color="getStatusColor(item.status)" size="small">
|
||||||
|
{{ getStatusText(item.status) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
workflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stageTimeChart = ref(null)
|
||||||
|
const delayDistributionChart = ref(null)
|
||||||
|
const yearOverYearChart = ref(null)
|
||||||
|
const seasonalAnalysisChart = ref(null)
|
||||||
|
|
||||||
|
let stageTimeChartInstance = null
|
||||||
|
let delayDistributionChartInstance = null
|
||||||
|
let yearOverYearChartInstance = null
|
||||||
|
let seasonalAnalysisChartInstance = null
|
||||||
|
|
||||||
|
const weekDays = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']
|
||||||
|
|
||||||
|
const averageCompletionTime = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const completedWorkflows = props.workflows.filter(w => w.status === 'completed' && w.dateSubmit && w.dateCompleted)
|
||||||
|
if (completedWorkflows.length === 0) return 0
|
||||||
|
|
||||||
|
const totalDays = completedWorkflows.reduce((sum, w) => {
|
||||||
|
const startDate = new Date(w.dateSubmit)
|
||||||
|
const endDate = new Date(w.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
return sum + Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return Math.round(totalDays / completedWorkflows.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const delayedWorkflowsCount = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
return props.workflows.filter(w => {
|
||||||
|
if (w.status !== 'completed') return true
|
||||||
|
if (!w.dateSubmit || !w.dateCompleted) return false
|
||||||
|
|
||||||
|
const startDate = new Date(w.dateSubmit)
|
||||||
|
const endDate = new Date(w.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return diffDays > 30
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const efficiencyRate = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const totalWorkflows = props.workflows.length
|
||||||
|
if (totalWorkflows === 0) return 0
|
||||||
|
|
||||||
|
const efficientWorkflows = props.workflows.filter(w => {
|
||||||
|
if (w.status !== 'completed') return false
|
||||||
|
if (!w.dateSubmit || !w.dateCompleted) return false
|
||||||
|
|
||||||
|
const startDate = new Date(w.dateSubmit)
|
||||||
|
const endDate = new Date(w.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return diffDays <= 30
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return Math.round((efficientWorkflows / totalWorkflows) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const peakSeason = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 'نامشخص'
|
||||||
|
|
||||||
|
const seasonalData = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const month = persianDate.month() + 1
|
||||||
|
const season = getSeason(month)
|
||||||
|
seasonalData[season] = (seasonalData[season] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Object.keys(seasonalData).length === 0) return 'نامشخص'
|
||||||
|
|
||||||
|
return Object.entries(seasonalData)
|
||||||
|
.reduce((a, b) => a[1] > b[1] ? a : b)[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const delayHeaders = [
|
||||||
|
{ title: 'کد', key: 'code', sortable: true },
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'روزهای تأخیر', key: 'delayDays', sortable: true },
|
||||||
|
{ title: 'مبلغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'وضعیت', key: 'status', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const stageHeaders = [
|
||||||
|
{ title: 'مرحله', key: 'stageName', sortable: true },
|
||||||
|
{ title: 'میانگین زمان', key: 'averageTime', sortable: true },
|
||||||
|
{ title: 'تعداد پرونده', key: 'workflowCount', sortable: true },
|
||||||
|
{ title: 'کارایی', key: 'efficiency', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const performanceHeaders = [
|
||||||
|
{ title: 'کد', key: 'code', sortable: true },
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'زمان تکمیل', key: 'completionTime', sortable: true },
|
||||||
|
{ title: 'وضعیت', key: 'status', sortable: true },
|
||||||
|
{ title: 'مبلغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'تاریخ ثبت', key: 'dateSubmit', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const delayedWorkflows = computed(() => {
|
||||||
|
return props.workflows.filter(w => {
|
||||||
|
if (w.status !== 'completed') return true
|
||||||
|
if (!w.dateSubmit || !w.dateCompleted) return false
|
||||||
|
|
||||||
|
const startDate = new Date(w.dateSubmit)
|
||||||
|
const endDate = new Date(w.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
return diffDays > 30
|
||||||
|
}).map(w => ({
|
||||||
|
code: w.code,
|
||||||
|
supplierName: w.supplierName,
|
||||||
|
delayDays: w.status === 'completed' ? calculateDelayDays(w) : 'در جریان',
|
||||||
|
totalAmount: w.totalAmount,
|
||||||
|
currency: w.currency,
|
||||||
|
status: w.status
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const stagePerformance = computed(() => {
|
||||||
|
const stages = [
|
||||||
|
{ name: 'ثبت پرونده', key: 'registration' },
|
||||||
|
{ name: 'تخصیص ارز', key: 'currency_allocation' },
|
||||||
|
{ name: 'حمل و نقل', key: 'transportation' },
|
||||||
|
{ name: 'ترخیص', key: 'clearance' },
|
||||||
|
{ name: 'تکمیل', key: 'completion' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return stages.map(stage => {
|
||||||
|
const stageWorkflows = props.workflows.filter(w => w.stage === stage.key)
|
||||||
|
const averageTime = stageWorkflows.length > 0 ?
|
||||||
|
stageWorkflows.reduce((sum, w) => sum + (w.stageDuration || 0), 0) / stageWorkflows.length : 0
|
||||||
|
const efficiency = stageWorkflows.length > 0 ?
|
||||||
|
(stageWorkflows.filter(w => w.stageStatus === 'completed').length / stageWorkflows.length) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
stageName: stage.name,
|
||||||
|
averageTime: Math.round(averageTime),
|
||||||
|
workflowCount: stageWorkflows.length,
|
||||||
|
efficiency: efficiency
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const performanceDetails = computed(() => {
|
||||||
|
return props.workflows.map(workflow => ({
|
||||||
|
code: workflow.code,
|
||||||
|
supplierName: workflow.supplierName,
|
||||||
|
completionTime: workflow.status === 'completed' ? calculateCompletionTime(workflow) : 'در جریان',
|
||||||
|
status: workflow.status,
|
||||||
|
totalAmount: workflow.totalAmount,
|
||||||
|
currency: workflow.currency,
|
||||||
|
dateSubmit: formatDate(workflow.dateSubmit)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSeason = (month) => {
|
||||||
|
if (month >= 1 && month <= 3) return 'بهار'
|
||||||
|
if (month >= 4 && month <= 6) return 'تابستان'
|
||||||
|
if (month >= 7 && month <= 9) return 'پاییز'
|
||||||
|
return 'زمستان'
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateDelayDays = (workflow) => {
|
||||||
|
const startDate = new Date(workflow.dateSubmit)
|
||||||
|
const endDate = new Date(workflow.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateCompletionTime = (workflow) => {
|
||||||
|
const startDate = new Date(workflow.dateSubmit)
|
||||||
|
const endDate = new Date(workflow.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
const persianDate = dayjs(date).calendar('jalali')
|
||||||
|
const year = persianDate.year()
|
||||||
|
const month = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const day = String(persianDate.date()).padStart(2, '0')
|
||||||
|
return `${year}/${month}/${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDelayColor = (days) => {
|
||||||
|
if (days <= 7) return 'success'
|
||||||
|
if (days <= 30) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const colors = {
|
||||||
|
'completed': 'success',
|
||||||
|
'in_progress': 'info',
|
||||||
|
'pending': 'warning',
|
||||||
|
'stopped': 'error'
|
||||||
|
}
|
||||||
|
return colors[status] || 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const texts = {
|
||||||
|
'completed': 'تکمیل شده',
|
||||||
|
'in_progress': 'در جریان',
|
||||||
|
'pending': 'در انتظار',
|
||||||
|
'stopped': 'متوقف شده'
|
||||||
|
}
|
||||||
|
return texts[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHeatmapColor = (dayIndex, hourIndex) => {
|
||||||
|
const value = getHeatmapValue(dayIndex, hourIndex)
|
||||||
|
const intensity = Math.min(value / 10, 1)
|
||||||
|
return `rgba(255, 99, 132, ${intensity})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getHeatmapValue = (dayIndex, hourIndex) => {
|
||||||
|
const dayNames = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']
|
||||||
|
const dayName = dayNames[dayIndex]
|
||||||
|
|
||||||
|
return props.workflows.filter(w => {
|
||||||
|
const date = new Date(w.dateSubmit)
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
const hour = date.getHours()
|
||||||
|
|
||||||
|
return dayOfWeek === dayIndex && hour === hourIndex
|
||||||
|
}).length
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCharts = () => {
|
||||||
|
// Destroy existing charts before creating new ones
|
||||||
|
if (stageTimeChartInstance) {
|
||||||
|
stageTimeChartInstance.destroy()
|
||||||
|
stageTimeChartInstance = null
|
||||||
|
}
|
||||||
|
if (delayDistributionChartInstance) {
|
||||||
|
delayDistributionChartInstance.destroy()
|
||||||
|
delayDistributionChartInstance = null
|
||||||
|
}
|
||||||
|
if (yearOverYearChartInstance) {
|
||||||
|
yearOverYearChartInstance.destroy()
|
||||||
|
yearOverYearChartInstance = null
|
||||||
|
}
|
||||||
|
if (seasonalAnalysisChartInstance) {
|
||||||
|
seasonalAnalysisChartInstance.destroy()
|
||||||
|
seasonalAnalysisChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new charts
|
||||||
|
createStageTimeChart()
|
||||||
|
createDelayDistributionChart()
|
||||||
|
createYearOverYearChart()
|
||||||
|
createSeasonalAnalysisChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStageTimeChart = () => {
|
||||||
|
const ctx = stageTimeChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (stageTimeChartInstance) {
|
||||||
|
stageTimeChartInstance.destroy()
|
||||||
|
stageTimeChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageData = stagePerformance.value
|
||||||
|
|
||||||
|
stageTimeChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: stageData.map(s => s.stageName),
|
||||||
|
datasets: [{
|
||||||
|
label: 'میانگین زمان (روز)',
|
||||||
|
data: stageData.map(s => s.averageTime),
|
||||||
|
backgroundColor: '#36A2EB'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createDelayDistributionChart = () => {
|
||||||
|
const ctx = delayDistributionChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (delayDistributionChartInstance) {
|
||||||
|
delayDistributionChartInstance.destroy()
|
||||||
|
delayDistributionChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const delays = {
|
||||||
|
'کمتر از 7 روز': 0,
|
||||||
|
'7-30 روز': 0,
|
||||||
|
'30-90 روز': 0,
|
||||||
|
'بیش از 90 روز': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
if (workflow.status === 'completed' && workflow.dateSubmit && workflow.dateCompleted) {
|
||||||
|
const delayDays = calculateDelayDays(workflow)
|
||||||
|
if (delayDays <= 7) delays['کمتر از 7 روز']++
|
||||||
|
else if (delayDays <= 30) delays['7-30 روز']++
|
||||||
|
else if (delayDays <= 90) delays['30-90 روز']++
|
||||||
|
else delays['بیش از 90 روز']++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
delayDistributionChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(delays),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(delays),
|
||||||
|
backgroundColor: ['#4CAF50', '#FF9800', '#FF5722', '#F44336']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createYearOverYearChart = () => {
|
||||||
|
const ctx = yearOverYearChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (yearOverYearChartInstance) {
|
||||||
|
yearOverYearChartInstance.destroy()
|
||||||
|
yearOverYearChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearlyData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const year = new Date(workflow.dateSubmit).getFullYear()
|
||||||
|
if (!yearlyData[year]) {
|
||||||
|
yearlyData[year] = { count: 0, amount: 0 }
|
||||||
|
}
|
||||||
|
yearlyData[year].count++
|
||||||
|
yearlyData[year].amount += workflow.totalAmount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedYears = Object.keys(yearlyData).sort()
|
||||||
|
const countData = sortedYears.map(year => yearlyData[year].count)
|
||||||
|
const amountData = sortedYears.map(year => yearlyData[year].amount)
|
||||||
|
|
||||||
|
yearOverYearChartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: sortedYears,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'تعداد پروندهها',
|
||||||
|
data: countData,
|
||||||
|
borderColor: '#36A2EB',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'مجموع مبالغ',
|
||||||
|
data: amountData,
|
||||||
|
borderColor: '#FF6384',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSeasonalAnalysisChart = () => {
|
||||||
|
const ctx = seasonalAnalysisChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const seasonalData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const month = persianDate.month() + 1
|
||||||
|
const season = getSeason(month)
|
||||||
|
seasonalData[season] = (seasonalData[season] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
seasonalAnalysisChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(seasonalData),
|
||||||
|
datasets: [{
|
||||||
|
label: 'تعداد پروندهها',
|
||||||
|
data: Object.values(seasonalData),
|
||||||
|
backgroundColor: ['#4CAF50', '#FF9800', '#FF5722', '#2196F3']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (stageTimeChartInstance) stageTimeChartInstance.destroy()
|
||||||
|
if (delayDistributionChartInstance) delayDistributionChartInstance.destroy()
|
||||||
|
if (yearOverYearChartInstance) yearOverYearChartInstance.destroy()
|
||||||
|
if (seasonalAnalysisChartInstance) seasonalAnalysisChartInstance.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.workflows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.performance-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.performance-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.average-time-card { border-left: 4px solid #1976d2; }
|
||||||
|
.delayed-workflows-card { border-left: 4px solid #ff9800; }
|
||||||
|
.efficiency-card { border-left: 4px solid #4caf50; }
|
||||||
|
.seasonal-peak-card { border-left: 4px solid #2196f3; }
|
||||||
|
|
||||||
|
.heatmap-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-day-label {
|
||||||
|
width: 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
689
webUI/src/components/plugins/import-workflow/ProductsTab.vue
Normal file
689
webUI/src/components/plugins/import-workflow/ProductsTab.vue
Normal file
|
|
@ -0,0 +1,689 @@
|
||||||
|
<template>
|
||||||
|
<div class="products-tab">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="product-card total-products-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="primary" class="ml-3">mdi-package-variant</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ totalProducts }}</div>
|
||||||
|
<div class="text-caption">کل محصولات</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="product-card categories-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-tag-multiple</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ totalCategories }}</div>
|
||||||
|
<div class="text-caption">دستهبندیها</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="product-card pareto-percentage-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-chart-line</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ paretoPercentage }}%</div>
|
||||||
|
<div class="text-caption">سهم ۲۰٪ برتر</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="product-card average-weight-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-weight</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ averageWeight }}</div>
|
||||||
|
<div class="text-caption">میانگین وزن (کیلوگرم)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>توزیع محصولات بر اساس دستهبندی</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="categoryChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-pie</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>نمودار Pareto (80/20)</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="paretoChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-line</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>روند واردات محصولات</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="productTrendChart" width="800" height="300"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-line</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>Top 10 محصولات بر اساس مبلغ</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="(product, index) in topProductsByAmount" :key="index">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar :color="getProductColor(index)" size="32">
|
||||||
|
<span class="text-white font-weight-bold">{{ index + 1 }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ product.name || 'نامشخص' }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ product.count }} بار - {{ formatMoney(product.amount) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-chip :color="getCategoryColor(product.category)" size="small">
|
||||||
|
{{ product.category || 'نامشخص' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>تحلیل وزن محصولات</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="weightAnalysisChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>محصولات با بیشترین تکرار</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="frequencyHeaders" :items="productFrequency" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.averageAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.averageAmount) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.category="{ item }">
|
||||||
|
<v-chip :color="getCategoryColor(item.category)" size="small">
|
||||||
|
{{ item.category || 'نامشخص' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>تحلیل کد تعرفه</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="tariffHeaders" :items="tariffAnalysis" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.productCount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.productCount }} محصول
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.averageDuty="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.averageDuty }}%
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>جدول تفصیلی محصولات</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="productHeaders" :items="productDetails" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }" hover>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.weight="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.weight }} کیلوگرم
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.category="{ item }">
|
||||||
|
<v-chip :color="getCategoryColor(item.category)" size="small">
|
||||||
|
{{ item.category || 'نامشخص' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.actions="{ item }">
|
||||||
|
<v-btn color="primary" variant="text" size="small" @click="viewProductDetails(item)"
|
||||||
|
prepend-icon="mdi-eye">
|
||||||
|
جزئیات
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
workflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryChart = ref(null)
|
||||||
|
const paretoChart = ref(null)
|
||||||
|
const productTrendChart = ref(null)
|
||||||
|
const weightAnalysisChart = ref(null)
|
||||||
|
|
||||||
|
let categoryChartInstance = null
|
||||||
|
let paretoChartInstance = null
|
||||||
|
let productTrendChartInstance = null
|
||||||
|
let weightAnalysisChartInstance = null
|
||||||
|
|
||||||
|
const totalProducts = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
const products = new Set(props.workflows.map(w => w.productName).filter(Boolean))
|
||||||
|
return products.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCategories = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
const categories = new Set(props.workflows.map(w => w.productCategory).filter(Boolean))
|
||||||
|
return categories.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const paretoPercentage = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const sortedProducts = productAnalysis.value
|
||||||
|
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||||
|
|
||||||
|
if (sortedProducts.length === 0) return 0
|
||||||
|
|
||||||
|
const top20Count = Math.ceil(sortedProducts.length * 0.2)
|
||||||
|
const top20Amount = sortedProducts.slice(0, top20Count)
|
||||||
|
.reduce((sum, product) => sum + product.totalAmount, 0)
|
||||||
|
const totalAmount = sortedProducts.reduce((sum, product) => sum + product.totalAmount, 0)
|
||||||
|
|
||||||
|
return Math.round((top20Amount / totalAmount) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageWeight = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const workflowsWithWeight = props.workflows.filter(w => w.productWeight)
|
||||||
|
if (workflowsWithWeight.length === 0) return 0
|
||||||
|
|
||||||
|
const totalWeight = workflowsWithWeight.reduce((sum, w) => sum + (w.productWeight || 0), 0)
|
||||||
|
return Math.round(totalWeight / workflowsWithWeight.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const productHeaders = [
|
||||||
|
{ title: 'نام محصول', key: 'productName', sortable: true },
|
||||||
|
{ title: 'دستهبندی', key: 'category', sortable: true },
|
||||||
|
{ title: 'کد تعرفه', key: 'tariffCode', sortable: true },
|
||||||
|
{ title: 'وزن', key: 'weight', sortable: true },
|
||||||
|
{ title: 'مبلغ کل', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'تعداد', key: 'count', sortable: true },
|
||||||
|
{ title: 'عملیات', key: 'actions', sortable: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
const frequencyHeaders = [
|
||||||
|
{ title: 'نام محصول', key: 'productName', sortable: true },
|
||||||
|
{ title: 'تعداد تکرار', key: 'frequency', sortable: true },
|
||||||
|
{ title: 'مجموع مبلغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'میانگین مبلغ', key: 'averageAmount', sortable: true },
|
||||||
|
{ title: 'دستهبندی', key: 'category', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const tariffHeaders = [
|
||||||
|
{ title: 'کد تعرفه', key: 'tariffCode', sortable: true },
|
||||||
|
{ title: 'تعداد محصولات', key: 'productCount', sortable: true },
|
||||||
|
{ title: 'مجموع مبلغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'میانگین تعرفه', key: 'averageDuty', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const productAnalysis = computed(() => {
|
||||||
|
const products = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const productName = workflow.productName || 'نامشخص'
|
||||||
|
if (!products[productName]) {
|
||||||
|
products[productName] = {
|
||||||
|
productName,
|
||||||
|
count: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
category: workflow.productCategory || 'نامشخص',
|
||||||
|
tariffCode: workflow.tariffCode || 'نامشخص',
|
||||||
|
weight: workflow.productWeight || 0,
|
||||||
|
currency: workflow.currency || 'نامشخص'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
products[productName].count++
|
||||||
|
products[productName].totalAmount += workflow.totalAmount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(products).map(product => ({
|
||||||
|
...product,
|
||||||
|
averageAmount: product.count > 0 ? product.totalAmount / product.count : 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const topProductsByAmount = computed(() => {
|
||||||
|
return productAnalysis.value
|
||||||
|
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||||
|
.slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const productFrequency = computed(() => {
|
||||||
|
return productAnalysis.value
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tariffAnalysis = computed(() => {
|
||||||
|
const tariffs = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const tariffCode = workflow.tariffCode || 'نامشخص'
|
||||||
|
if (!tariffs[tariffCode]) {
|
||||||
|
tariffs[tariffCode] = {
|
||||||
|
tariffCode,
|
||||||
|
productCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
totalDuty: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tariffs[tariffCode].productCount++
|
||||||
|
tariffs[tariffCode].totalAmount += workflow.totalAmount || 0
|
||||||
|
tariffs[tariffCode].totalDuty += workflow.dutyRate || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(tariffs).map(tariff => ({
|
||||||
|
...tariff,
|
||||||
|
averageDuty: tariff.productCount > 0 ? Math.round(tariff.totalDuty / tariff.productCount) : 0
|
||||||
|
})).sort((a, b) => b.totalAmount - a.totalAmount)
|
||||||
|
})
|
||||||
|
|
||||||
|
const productDetails = computed(() => {
|
||||||
|
return productAnalysis.value.map(product => ({
|
||||||
|
productName: product.productName,
|
||||||
|
category: product.category,
|
||||||
|
tariffCode: product.tariffCode,
|
||||||
|
weight: product.weight,
|
||||||
|
totalAmount: product.totalAmount,
|
||||||
|
currency: product.currency,
|
||||||
|
count: product.count
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProductColor = (index) => {
|
||||||
|
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384']
|
||||||
|
return colors[index] || '#C9CBCF'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCategoryColor = (category) => {
|
||||||
|
const colors = {
|
||||||
|
'الکترونیک': 'primary',
|
||||||
|
'مکانیک': 'success',
|
||||||
|
'شیمیایی': 'warning',
|
||||||
|
'نساجی': 'info',
|
||||||
|
'غذایی': 'error',
|
||||||
|
'سایر': 'grey'
|
||||||
|
}
|
||||||
|
return colors[category] || 'grey'
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCharts = () => {
|
||||||
|
// Destroy existing charts before creating new ones
|
||||||
|
if (categoryChartInstance) {
|
||||||
|
categoryChartInstance.destroy()
|
||||||
|
categoryChartInstance = null
|
||||||
|
}
|
||||||
|
if (paretoChartInstance) {
|
||||||
|
paretoChartInstance.destroy()
|
||||||
|
paretoChartInstance = null
|
||||||
|
}
|
||||||
|
if (productTrendChartInstance) {
|
||||||
|
productTrendChartInstance.destroy()
|
||||||
|
productTrendChartInstance = null
|
||||||
|
}
|
||||||
|
if (weightAnalysisChartInstance) {
|
||||||
|
weightAnalysisChartInstance.destroy()
|
||||||
|
weightAnalysisChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new charts
|
||||||
|
createCategoryChart()
|
||||||
|
createParetoChart()
|
||||||
|
createProductTrendChart()
|
||||||
|
createWeightAnalysisChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCategoryChart = () => {
|
||||||
|
const ctx = categoryChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (categoryChartInstance) {
|
||||||
|
categoryChartInstance.destroy()
|
||||||
|
categoryChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const category = workflow.productCategory || 'نامشخص'
|
||||||
|
categoryData[category] = (categoryData[category] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
categoryChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(categoryData),
|
||||||
|
datasets: [{
|
||||||
|
data: Object.values(categoryData),
|
||||||
|
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createParetoChart = () => {
|
||||||
|
const ctx = paretoChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (paretoChartInstance) {
|
||||||
|
paretoChartInstance.destroy()
|
||||||
|
paretoChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedProducts = productAnalysis.value
|
||||||
|
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||||
|
.slice(0, 10)
|
||||||
|
|
||||||
|
const labels = sortedProducts.map(p => p.productName)
|
||||||
|
const amounts = sortedProducts.map(p => p.totalAmount)
|
||||||
|
|
||||||
|
const cumulativeAmounts = amounts.map((amount, index) => {
|
||||||
|
return amounts.slice(0, index + 1).reduce((sum, val) => sum + val, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalAmount = amounts.reduce((sum, amount) => sum + amount, 0)
|
||||||
|
const cumulativePercentages = cumulativeAmounts.map(cumulative => (cumulative / totalAmount) * 100)
|
||||||
|
|
||||||
|
paretoChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'مبلغ (ارزی)',
|
||||||
|
data: amounts,
|
||||||
|
backgroundColor: '#36A2EB',
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'درصد تجمعی',
|
||||||
|
data: cumulativePercentages,
|
||||||
|
type: 'line',
|
||||||
|
borderColor: '#FF6384',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
max: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProductTrendChart = () => {
|
||||||
|
const ctx = productTrendChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const monthlyData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const persianYear = persianDate.year()
|
||||||
|
const persianMonth = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const monthKey = `${persianYear}-${persianMonth}`
|
||||||
|
monthlyData[monthKey] = (monthlyData[monthKey] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedMonths = Object.keys(monthlyData).sort()
|
||||||
|
const labels = sortedMonths.map(month => {
|
||||||
|
const [year, monthNum] = month.split('-')
|
||||||
|
return `${year}/${monthNum}`
|
||||||
|
})
|
||||||
|
|
||||||
|
productTrendChartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'تعداد محصولات',
|
||||||
|
data: sortedMonths.map(month => monthlyData[month]),
|
||||||
|
borderColor: '#4CAF50',
|
||||||
|
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createWeightAnalysisChart = () => {
|
||||||
|
const ctx = weightAnalysisChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const weightRanges = {
|
||||||
|
'کمتر از 1 کیلو': 0,
|
||||||
|
'1-10 کیلو': 0,
|
||||||
|
'10-100 کیلو': 0,
|
||||||
|
'100-1000 کیلو': 0,
|
||||||
|
'بیش از 1000 کیلو': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const weight = workflow.productWeight || 0
|
||||||
|
if (weight < 1) weightRanges['کمتر از 1 کیلو']++
|
||||||
|
else if (weight < 10) weightRanges['1-10 کیلو']++
|
||||||
|
else if (weight < 100) weightRanges['10-100 کیلو']++
|
||||||
|
else if (weight < 1000) weightRanges['100-1000 کیلو']++
|
||||||
|
else weightRanges['بیش از 1000 کیلو']++
|
||||||
|
})
|
||||||
|
|
||||||
|
weightAnalysisChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: Object.keys(weightRanges),
|
||||||
|
datasets: [{
|
||||||
|
label: 'تعداد محصولات',
|
||||||
|
data: Object.values(weightRanges),
|
||||||
|
backgroundColor: '#FF9800'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewProductDetails = (product) => {
|
||||||
|
console.log('View product details:', product)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (categoryChartInstance) categoryChartInstance.destroy()
|
||||||
|
if (paretoChartInstance) paretoChartInstance.destroy()
|
||||||
|
if (productTrendChartInstance) productTrendChartInstance.destroy()
|
||||||
|
if (weightAnalysisChartInstance) weightAnalysisChartInstance.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.workflows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-products-card { border-left: 4px solid #1976d2; }
|
||||||
|
.categories-card { border-left: 4px solid #4caf50; }
|
||||||
|
.pareto-percentage-card { border-left: 4px solid #2196f3; }
|
||||||
|
.average-weight-card { border-left: 4px solid #ff9800; }
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
592
webUI/src/components/plugins/import-workflow/SuppliersTab.vue
Normal file
592
webUI/src/components/plugins/import-workflow/SuppliersTab.vue
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
<template>
|
||||||
|
<div class="suppliers-tab">
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="supplier-card total-suppliers-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="primary" class="ml-3">mdi-account-group</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ totalSuppliers }}</div>
|
||||||
|
<div class="text-caption">کل تأمینکنندگان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="supplier-card active-suppliers-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="success" class="ml-3">mdi-account-check</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ activeSuppliers }}</div>
|
||||||
|
<div class="text-caption">تأمینکنندگان فعال</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="supplier-card average-collaboration-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="info" class="ml-3">mdi-calendar-range</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ averageCollaborationDays }}</div>
|
||||||
|
<div class="text-caption">میانگین مدت همکاری (روز)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<v-card class="supplier-card risk-score-card">
|
||||||
|
<v-card-text>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon size="32" color="warning" class="ml-3">mdi-alert-circle</v-icon>
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">{{ averageRiskScore }}%</div>
|
||||||
|
<div class="text-caption">میانگین ریسک تأمینکنندگان</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>رتبهبندی تأمینکنندگان بر اساس حجم</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="supplierRankingChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-bar</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>توزیع ریسک تأمینکنندگان</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="riskDistributionChart" width="400" height="200"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-donut</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>روند همکاری با تأمینکنندگان</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<canvas v-if="workflows && workflows.length > 0" ref="collaborationTrendChart" width="800" height="300"></canvas>
|
||||||
|
<div v-if="!workflows || workflows.length === 0" class="text-center pa-4">
|
||||||
|
<v-icon size="48" color="grey">mdi-chart-line</v-icon>
|
||||||
|
<div class="text-h6 text-grey mt-2">دادهای برای نمایش وجود ندارد</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>Top 10 تأمینکنندگان بر اساس مبلغ</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="(supplier, index) in topSuppliersByAmount" :key="index">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar :color="getSupplierColor(index)" size="32">
|
||||||
|
<span class="text-white font-weight-bold">{{ index + 1 }}</span>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ supplier.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ supplier.workflowCount }} پرونده - {{ formatMoney(supplier.totalAmount) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-chip :color="getRiskColor(supplier.riskScore)" size="small">
|
||||||
|
ریسک: {{ supplier.riskScore }}%
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title>تأمینکنندگان با بالاترین ریسک</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="riskHeaders" :items="highRiskSuppliers" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }">
|
||||||
|
<template v-slot:item.riskScore="{ item }">
|
||||||
|
<v-chip :color="getRiskColor(item.riskScore)" size="small">
|
||||||
|
{{ item.riskScore }}%
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.delayPercentage="{ item }">
|
||||||
|
<v-progress-linear :model-value="item.delayPercentage" color="error" height="20">
|
||||||
|
<template v-slot:default>
|
||||||
|
{{ item.delayPercentage.toFixed(1) }}%
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>جدول تفصیلی تأمینکنندگان</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-data-table :headers="supplierHeaders" :items="supplierAnalysis" density="comfortable"
|
||||||
|
class="elevation-1" :header-props="{ class: 'custom-header' }" hover>
|
||||||
|
<template v-slot:item.totalAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.totalAmount) }}
|
||||||
|
<small class="text-medium-emphasis">{{ item.currency }}</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.averageAmount="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ formatMoney(item.averageAmount) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.riskScore="{ item }">
|
||||||
|
<v-chip :color="getRiskColor(item.riskScore)" size="small">
|
||||||
|
{{ item.riskScore }}%
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.collaborationDuration="{ item }">
|
||||||
|
<div>
|
||||||
|
{{ item.collaborationDuration }} روز
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-slot:item.actions="{ item }">
|
||||||
|
<v-btn color="primary" variant="text" size="small" @click="viewSupplierDetails(item)"
|
||||||
|
prepend-icon="mdi-eye">
|
||||||
|
جزئیات
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
workflows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const supplierRankingChart = ref(null)
|
||||||
|
const riskDistributionChart = ref(null)
|
||||||
|
const collaborationTrendChart = ref(null)
|
||||||
|
|
||||||
|
let supplierRankingChartInstance = null
|
||||||
|
let riskDistributionChartInstance = null
|
||||||
|
let collaborationTrendChartInstance = null
|
||||||
|
|
||||||
|
const totalSuppliers = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
const suppliers = new Set(props.workflows.map(w => w.supplierName).filter(Boolean))
|
||||||
|
return suppliers.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSuppliers = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
const activeWorkflows = props.workflows.filter(w => w.status === 'in_progress' || w.status === 'completed')
|
||||||
|
const suppliers = new Set(activeWorkflows.map(w => w.supplierName).filter(Boolean))
|
||||||
|
return suppliers.size
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageCollaborationDays = computed(() => {
|
||||||
|
if (!props.workflows || !Array.isArray(props.workflows)) return 0
|
||||||
|
|
||||||
|
const suppliers = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const supplierName = workflow.supplierName
|
||||||
|
if (!supplierName) return
|
||||||
|
|
||||||
|
if (!suppliers[supplierName]) {
|
||||||
|
suppliers[supplierName] = {
|
||||||
|
firstDate: new Date(workflow.dateSubmit),
|
||||||
|
lastDate: new Date(workflow.dateSubmit)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const workflowDate = new Date(workflow.dateSubmit)
|
||||||
|
if (workflowDate < suppliers[supplierName].firstDate) {
|
||||||
|
suppliers[supplierName].firstDate = workflowDate
|
||||||
|
}
|
||||||
|
if (workflowDate > suppliers[supplierName].lastDate) {
|
||||||
|
suppliers[supplierName].lastDate = workflowDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const collaborationDays = Object.values(suppliers).map(supplier => {
|
||||||
|
const diffTime = Math.abs(supplier.lastDate - supplier.firstDate)
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (collaborationDays.length === 0) return 0
|
||||||
|
return Math.round(collaborationDays.reduce((sum, days) => sum + days, 0) / collaborationDays.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageRiskScore = computed(() => {
|
||||||
|
const suppliers = supplierAnalysis.value
|
||||||
|
if (suppliers.length === 0) return 0
|
||||||
|
|
||||||
|
const totalRisk = suppliers.reduce((sum, supplier) => sum + supplier.riskScore, 0)
|
||||||
|
return Math.round(totalRisk / suppliers.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const supplierHeaders = [
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'تعداد پرونده', key: 'workflowCount', sortable: true },
|
||||||
|
{ title: 'مجموع مبالغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'میانگین مبلغ', key: 'averageAmount', sortable: true },
|
||||||
|
{ title: 'واحد پول غالب', key: 'dominantCurrency', sortable: true },
|
||||||
|
{ title: 'امتیاز ریسک', key: 'riskScore', sortable: true },
|
||||||
|
{ title: 'مدت همکاری', key: 'collaborationDuration', sortable: true },
|
||||||
|
{ title: 'عملیات', key: 'actions', sortable: false }
|
||||||
|
]
|
||||||
|
|
||||||
|
const riskHeaders = [
|
||||||
|
{ title: 'تامینکننده', key: 'supplierName', sortable: true },
|
||||||
|
{ title: 'امتیاز ریسک', key: 'riskScore', sortable: true },
|
||||||
|
{ title: 'تعداد پرونده', key: 'workflowCount', sortable: true },
|
||||||
|
{ title: 'مجموع مبالغ', key: 'totalAmount', sortable: true },
|
||||||
|
{ title: 'درصد تأخیر', key: 'delayPercentage', sortable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const supplierAnalysis = computed(() => {
|
||||||
|
const suppliers = {}
|
||||||
|
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const supplierName = workflow.supplierName || 'نامشخص'
|
||||||
|
if (!suppliers[supplierName]) {
|
||||||
|
suppliers[supplierName] = {
|
||||||
|
supplierName,
|
||||||
|
workflowCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
currencies: {},
|
||||||
|
firstDate: new Date(workflow.dateSubmit),
|
||||||
|
lastDate: new Date(workflow.dateSubmit),
|
||||||
|
delayedWorkflows: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suppliers[supplierName].workflowCount++
|
||||||
|
suppliers[supplierName].totalAmount += parseFloat(workflow.totalAmount || 0)
|
||||||
|
|
||||||
|
const currency = workflow.currency || 'نامشخص'
|
||||||
|
suppliers[supplierName].currencies[currency] = (suppliers[supplierName].currencies[currency] || 0) + 1
|
||||||
|
|
||||||
|
const workflowDate = new Date(workflow.dateSubmit)
|
||||||
|
if (workflowDate < suppliers[supplierName].firstDate) {
|
||||||
|
suppliers[supplierName].firstDate = workflowDate
|
||||||
|
}
|
||||||
|
if (workflowDate > suppliers[supplierName].lastDate) {
|
||||||
|
suppliers[supplierName].lastDate = workflowDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.status === 'stopped' || workflow.status === 'pending') {
|
||||||
|
suppliers[supplierName].delayedWorkflows++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.values(suppliers).map(supplier => {
|
||||||
|
const currencies = Object.entries(supplier.currencies)
|
||||||
|
const dominantCurrency = currencies.length > 0
|
||||||
|
? currencies.reduce((a, b) => a[1] > b[1] ? a : b)[0]
|
||||||
|
: 'نامشخص'
|
||||||
|
|
||||||
|
const collaborationDuration = Math.ceil(
|
||||||
|
Math.abs(supplier.lastDate - supplier.firstDate) / (1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
|
||||||
|
const riskScore = calculateRiskScore(supplier)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...supplier,
|
||||||
|
averageAmount: supplier.workflowCount > 0 ? supplier.totalAmount / supplier.workflowCount : 0,
|
||||||
|
dominantCurrency,
|
||||||
|
collaborationDuration,
|
||||||
|
riskScore
|
||||||
|
}
|
||||||
|
}).sort((a, b) => b.totalAmount - a.totalAmount)
|
||||||
|
})
|
||||||
|
|
||||||
|
const topSuppliersByAmount = computed(() => {
|
||||||
|
return supplierAnalysis.value.slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const highRiskSuppliers = computed(() => {
|
||||||
|
return supplierAnalysis.value
|
||||||
|
.filter(supplier => supplier.riskScore > 50)
|
||||||
|
.sort((a, b) => b.riskScore - a.riskScore)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(supplier => ({
|
||||||
|
...supplier,
|
||||||
|
delayPercentage: supplier.workflowCount > 0 ? (supplier.delayedWorkflows / supplier.workflowCount) * 100 : 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const calculateRiskScore = (supplier) => {
|
||||||
|
let score = 0
|
||||||
|
|
||||||
|
const delayPercentage = supplier.workflowCount > 0 ? (supplier.delayedWorkflows / supplier.workflowCount) * 100 : 0
|
||||||
|
score += delayPercentage * 0.5
|
||||||
|
|
||||||
|
if (supplier.workflowCount < 3) score += 20
|
||||||
|
if (supplier.workflowCount > 20) score += 10
|
||||||
|
|
||||||
|
const collaborationDuration = Math.ceil(
|
||||||
|
Math.abs(supplier.lastDate - supplier.firstDate) / (1000 * 60 * 60 * 24)
|
||||||
|
)
|
||||||
|
if (collaborationDuration < 30) score += 30
|
||||||
|
if (collaborationDuration > 365) score -= 20
|
||||||
|
|
||||||
|
return Math.min(Math.max(Math.round(score), 0), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSupplierColor = (index) => {
|
||||||
|
const colors = ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384']
|
||||||
|
return colors[index] || '#C9CBCF'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRiskColor = (score) => {
|
||||||
|
if (score < 30) return 'success'
|
||||||
|
if (score < 60) return 'warning'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCharts = () => {
|
||||||
|
// Destroy existing charts before creating new ones
|
||||||
|
if (supplierRankingChartInstance) {
|
||||||
|
supplierRankingChartInstance.destroy()
|
||||||
|
supplierRankingChartInstance = null
|
||||||
|
}
|
||||||
|
if (riskDistributionChartInstance) {
|
||||||
|
riskDistributionChartInstance.destroy()
|
||||||
|
riskDistributionChartInstance = null
|
||||||
|
}
|
||||||
|
if (collaborationTrendChartInstance) {
|
||||||
|
collaborationTrendChartInstance.destroy()
|
||||||
|
collaborationTrendChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new charts
|
||||||
|
createSupplierRankingChart()
|
||||||
|
createRiskDistributionChart()
|
||||||
|
createCollaborationTrendChart()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSupplierRankingChart = () => {
|
||||||
|
const ctx = supplierRankingChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (supplierRankingChartInstance) {
|
||||||
|
supplierRankingChartInstance.destroy()
|
||||||
|
supplierRankingChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSuppliers = topSuppliersByAmount.value.slice(0, 8)
|
||||||
|
|
||||||
|
supplierRankingChartInstance = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: topSuppliers.map(s => s.supplierName),
|
||||||
|
datasets: [{
|
||||||
|
label: 'مجموع مبالغ',
|
||||||
|
data: topSuppliers.map(s => s.totalAmount),
|
||||||
|
backgroundColor: '#36A2EB'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRiskDistributionChart = () => {
|
||||||
|
const ctx = riskDistributionChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (riskDistributionChartInstance) {
|
||||||
|
riskDistributionChartInstance.destroy()
|
||||||
|
riskDistributionChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowRisk = supplierAnalysis.value.filter(s => s.riskScore < 30).length
|
||||||
|
const mediumRisk = supplierAnalysis.value.filter(s => s.riskScore >= 30 && s.riskScore < 60).length
|
||||||
|
const highRisk = supplierAnalysis.value.filter(s => s.riskScore >= 60).length
|
||||||
|
|
||||||
|
riskDistributionChartInstance = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['ریسک کم', 'ریسک متوسط', 'ریسک بالا'],
|
||||||
|
datasets: [{
|
||||||
|
data: [lowRisk, mediumRisk, highRisk],
|
||||||
|
backgroundColor: ['#4CAF50', '#FF9800', '#F44336']
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCollaborationTrendChart = () => {
|
||||||
|
const ctx = collaborationTrendChart.value?.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// Check if chart already exists and destroy it
|
||||||
|
if (collaborationTrendChartInstance) {
|
||||||
|
collaborationTrendChartInstance.destroy()
|
||||||
|
collaborationTrendChartInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyData = {}
|
||||||
|
props.workflows.forEach(workflow => {
|
||||||
|
const persianDate = dayjs(workflow.dateSubmit).calendar('jalali')
|
||||||
|
const persianYear = persianDate.year()
|
||||||
|
const persianMonth = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const monthKey = `${persianYear}-${persianMonth}`
|
||||||
|
monthlyData[monthKey] = (monthlyData[monthKey] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedMonths = Object.keys(monthlyData).sort()
|
||||||
|
const labels = sortedMonths.map(month => {
|
||||||
|
const [year, monthNum] = month.split('-')
|
||||||
|
return `${year}/${monthNum}`
|
||||||
|
})
|
||||||
|
|
||||||
|
collaborationTrendChartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'تعداد پروندههای جدید',
|
||||||
|
data: sortedMonths.map(month => monthlyData[month]),
|
||||||
|
borderColor: '#4CAF50',
|
||||||
|
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewSupplierDetails = (supplier) => {
|
||||||
|
console.log('View supplier details:', supplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (supplierRankingChartInstance) supplierRankingChartInstance.destroy()
|
||||||
|
if (riskDistributionChartInstance) riskDistributionChartInstance.destroy()
|
||||||
|
if (collaborationTrendChartInstance) collaborationTrendChartInstance.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.workflows, () => {
|
||||||
|
nextTick(() => {
|
||||||
|
createCharts()
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.supplier-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.supplier-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-suppliers-card { border-left: 4px solid #1976d2; }
|
||||||
|
.active-suppliers-card { border-left: 4px solid #4caf50; }
|
||||||
|
.average-collaboration-card { border-left: 4px solid #2196f3; }
|
||||||
|
.risk-score-card { border-left: 4px solid #ff9800; }
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-header {
|
||||||
|
background-color: #f5f5f5 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1153,6 +1153,16 @@ const router = createRouter({
|
||||||
name: 'import_workflow_intro',
|
name: 'import_workflow_intro',
|
||||||
component: () =>
|
component: () =>
|
||||||
import('../views/acc/plugins/import-workflow/intro.vue'),
|
import('../views/acc/plugins/import-workflow/intro.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plugins/import-workflow/reports',
|
||||||
|
name: 'import_workflow_reports',
|
||||||
|
component: () =>
|
||||||
|
import('../views/acc/plugins/import-workflow/reports.vue'),
|
||||||
|
meta: {
|
||||||
|
'title': 'گزارشات پروندههای واردات',
|
||||||
|
'login': true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ export default {
|
||||||
{ 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') },
|
||||||
{ path: '/acc/plugins/custominvoice/templates', key: 'I', label: 'قالبهای فاکتور', ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('custominvoice') },
|
{ path: '/acc/plugins/custominvoice/templates', key: 'I', label: 'قالبهای فاکتور', ctrl: true, shift: true, permission: () => this.permissions.settings && this.isPluginActive('custominvoice') },
|
||||||
{ path: '/acc/plugins/import-workflow', key: 'I', label: 'مدیریت واردات کالا', ctrl: true, shift: true, permission: () => this.permissions.plugImportWorkflow },
|
{ path: '/acc/plugins/import-workflow', key: 'I', label: 'مدیریت واردات کالا', ctrl: true, shift: true, permission: () => this.permissions.plugImportWorkflow },
|
||||||
|
{ path: '/acc/plugins/import-workflow/reports', key: 'I', label: 'گزارشات پروندههای واردات', ctrl: true, shift: true, permission: () => this.permissions.plugImportWorkflow },
|
||||||
{ path: '/acc/storeroom/tickets/list/helper', key: 'I', label: this.$t('drawer.storeroom_ticket_helper'), ctrl: true, shift: true, permission: () => (this.permissions.storehelper || this.permissions.store) && this.isPluginActive('accpro') },
|
{ path: '/acc/storeroom/tickets/list/helper', key: 'I', label: this.$t('drawer.storeroom_ticket_helper'), ctrl: true, shift: true, permission: () => (this.permissions.storehelper || this.permissions.store) && this.isPluginActive('accpro') },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
@ -821,6 +822,12 @@ export default {
|
||||||
<template v-slot:prepend><v-icon icon="mdi-import" color="primary"></v-icon></template>
|
<template v-slot:prepend><v-icon icon="mdi-import" color="primary"></v-icon></template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
<v-list-item v-if="permissions.plugImportWorkflow" to="/acc/plugins/import-workflow/reports">
|
||||||
|
<v-list-item-title>
|
||||||
|
گزارشات پروندههای واردات
|
||||||
|
<span v-if="isCtrlShiftPressed" class="shortcut-key">{{ getShortcutKey('/acc/plugins/import-workflow/reports') }}</span>
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item v-if="permissions.plugImportWorkflow" to="/acc/plugins/import-workflow/list">
|
<v-list-item v-if="permissions.plugImportWorkflow" to="/acc/plugins/import-workflow/list">
|
||||||
<v-list-item-title>
|
<v-list-item-title>
|
||||||
لیست پروندههای واردات
|
لیست پروندههای واردات
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,15 @@
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
/>
|
/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
prepend-icon="mdi-chart-line"
|
||||||
|
@click="goToReports"
|
||||||
|
variant="outlined"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
گزارشات
|
||||||
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-plus"
|
prepend-icon="mdi-plus"
|
||||||
|
|
@ -413,6 +422,10 @@ const onWorkflowCreated = () => {
|
||||||
// loadStats()
|
// loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToReports = () => {
|
||||||
|
router.push('/acc/plugins/import-workflow/reports')
|
||||||
|
}
|
||||||
|
|
||||||
const formatNumber = (number) => {
|
const formatNumber = (number) => {
|
||||||
if (!number) return '0'
|
if (!number) return '0'
|
||||||
return new Intl.NumberFormat('fa-IR').format(number)
|
return new Intl.NumberFormat('fa-IR').format(number)
|
||||||
|
|
|
||||||
386
webUI/src/views/acc/plugins/import-workflow/reports.vue
Normal file
386
webUI/src/views/acc/plugins/import-workflow/reports.vue
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
<template>
|
||||||
|
<div class="import-workflow-dashboard">
|
||||||
|
<v-container fluid>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4">
|
||||||
|
<v-card-title class="d-flex align-center justify-space-between">
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon class="ml-2" color="primary">mdi-chart-dashboard</v-icon>
|
||||||
|
<span class="text-h5">داشبورد گزارشات واردات</span>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="d-flex gap-2">
|
||||||
|
<v-btn color="success" @click="exportToExcel" :loading="exporting" prepend-icon="mdi-file-excel">
|
||||||
|
خروجی Excel
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="error" @click="exportToPDF" :loading="exporting" prepend-icon="mdi-file-pdf">
|
||||||
|
خروجی PDF
|
||||||
|
</v-btn>
|
||||||
|
</div> -->
|
||||||
|
</v-card-title>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<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="filters.currency" :items="currencyOptions" label="واحد پول"
|
||||||
|
clearable density="compact" variant="outlined"></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<h-date-picker v-model="filters.dateFrom" label="از تاریخ" density="compact"
|
||||||
|
variant="outlined" />
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="3">
|
||||||
|
<h-date-picker v-model="filters.dateTo" label="تا تاریخ" density="compact"
|
||||||
|
variant="outlined" />
|
||||||
|
</v-col>
|
||||||
|
<!-- <v-col cols="12" md="3">
|
||||||
|
<v-select v-model="filters.status" :items="statusOptions" label="وضعیت پرونده"
|
||||||
|
clearable density="compact" variant="outlined"></v-select>
|
||||||
|
</v-col> -->
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field v-model="filters.supplierSearch" label="جستجو در تامینکنندگان"
|
||||||
|
prepend-icon="mdi-magnify" clearable density="compact" variant="outlined"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field v-model="filters.countrySearch" label="جستجو در کشور مبدأ"
|
||||||
|
prepend-icon="mdi-earth" clearable density="compact" variant="outlined"></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-btn color="secondary" @click="clearFilters" variant="outlined" block>
|
||||||
|
پاک کردن فیلتر
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-tabs v-model="activeTab" color="primary" grow>
|
||||||
|
<v-tab value="dashboard">
|
||||||
|
<v-icon class="ml-2">mdi-view-dashboard</v-icon>
|
||||||
|
مدیریتی
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="financial">
|
||||||
|
<v-icon class="ml-2">mdi-currency-usd</v-icon>
|
||||||
|
مالی
|
||||||
|
</v-tab>
|
||||||
|
<!-- <v-tab value="suppliers">
|
||||||
|
<v-icon class="ml-2">mdi-account-group</v-icon>
|
||||||
|
تأمینکنندگان
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="performance">
|
||||||
|
<v-icon class="ml-2">mdi-clock-outline</v-icon>
|
||||||
|
عملکردی
|
||||||
|
</v-tab>
|
||||||
|
<v-tab value="products">
|
||||||
|
<v-icon class="ml-2">mdi-package-variant</v-icon>
|
||||||
|
کالاها
|
||||||
|
</v-tab> -->
|
||||||
|
</v-tabs>
|
||||||
|
|
||||||
|
<v-window v-model="activeTab" class="mt-4">
|
||||||
|
<v-window-item value="dashboard">
|
||||||
|
<DashboardTab :workflows="filteredWorkflows" :summary="summary" />
|
||||||
|
</v-window-item>
|
||||||
|
<v-window-item value="financial">
|
||||||
|
<FinancialTab :workflows="filteredWorkflows" />
|
||||||
|
</v-window-item>
|
||||||
|
<!-- <v-window-item value="suppliers">
|
||||||
|
<SuppliersTab :workflows="filteredWorkflows" />
|
||||||
|
</v-window-item>
|
||||||
|
<v-window-item value="performance">
|
||||||
|
<PerformanceTab :workflows="filteredWorkflows" />
|
||||||
|
</v-window-item>
|
||||||
|
<v-window-item value="products">
|
||||||
|
<ProductsTab :workflows="filteredWorkflows" />
|
||||||
|
</v-window-item> -->
|
||||||
|
</v-window>
|
||||||
|
</v-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import jalaliday from 'jalaliday'
|
||||||
|
import DashboardTab from '../../../../components/plugins/import-workflow/DashboardTab.vue'
|
||||||
|
import FinancialTab from '../../../../components/plugins/import-workflow/FinancialTab.vue'
|
||||||
|
import SuppliersTab from '../../../../components/plugins/import-workflow/SuppliersTab.vue'
|
||||||
|
import PerformanceTab from '../../../../components/plugins/import-workflow/PerformanceTab.vue'
|
||||||
|
import ProductsTab from '../../../../components/plugins/import-workflow/ProductsTab.vue'
|
||||||
|
|
||||||
|
dayjs.extend(jalaliday)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const workflows = ref([])
|
||||||
|
const activeTab = ref('dashboard')
|
||||||
|
|
||||||
|
const filters = ref({
|
||||||
|
currency: null,
|
||||||
|
dateFrom: null,
|
||||||
|
dateTo: null,
|
||||||
|
supplierSearch: '',
|
||||||
|
countrySearch: '',
|
||||||
|
status: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = ref({
|
||||||
|
totalWorkflows: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
totalPayments: 0,
|
||||||
|
totalPaymentsIRR: 0,
|
||||||
|
remainingAmount: 0,
|
||||||
|
completedWorkflows: 0,
|
||||||
|
inProgressWorkflows: 0,
|
||||||
|
averageCompletionTime: 0,
|
||||||
|
growthRate: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const currencyOptions = [
|
||||||
|
{ title: 'دلار آمریکا (USD)', value: 'USD' },
|
||||||
|
{ title: 'یورو (EUR)', value: 'EUR' },
|
||||||
|
{ title: 'پوند انگلیس (GBP)', value: 'GBP' },
|
||||||
|
{ title: 'یوان چین (CNY)', value: 'CNY' },
|
||||||
|
{ title: 'درهم امارات (AED)', value: 'AED' },
|
||||||
|
{ title: 'ریال (IRR)', value: 'IRR' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ title: 'در جریان', value: 'in_progress' },
|
||||||
|
{ title: 'تکمیل شده', value: 'completed' },
|
||||||
|
{ title: 'متوقف شده', value: 'stopped' },
|
||||||
|
{ title: 'در انتظار', value: 'pending' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const filteredWorkflows = computed(() => {
|
||||||
|
let filtered = workflows.value || []
|
||||||
|
|
||||||
|
if (filters.value.currency) {
|
||||||
|
filtered = filtered.filter(w => w.currency === filters.value.currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.value.dateFrom) {
|
||||||
|
const from = dayjs(filters.value.dateFrom, { jalali: true }).calendar('gregory').toDate()
|
||||||
|
filtered = filtered.filter(w => new Date(w.dateSubmit) >= from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.value.dateTo) {
|
||||||
|
const to = dayjs(filters.value.dateTo, { jalali: true }).calendar('gregory').toDate()
|
||||||
|
filtered = filtered.filter(w => new Date(w.dateSubmit) <= to)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.value.supplierSearch) {
|
||||||
|
const search = filters.value.supplierSearch.toLowerCase()
|
||||||
|
filtered = filtered.filter(w =>
|
||||||
|
w.supplierName && w.supplierName.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.value.countrySearch) {
|
||||||
|
const search = filters.value.countrySearch.toLowerCase()
|
||||||
|
filtered = filtered.filter(w =>
|
||||||
|
w.country && w.country.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.value.status) {
|
||||||
|
filtered = filtered.filter(w => w.status === filters.value.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadWorkflows = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/import-workflow/list')
|
||||||
|
|
||||||
|
if (response.data.Success) {
|
||||||
|
workflows.value = response.data.Result.data.map(workflow => ({
|
||||||
|
...workflow,
|
||||||
|
totalAmount: parseFloat(workflow.totalAmount || 0),
|
||||||
|
totalPayments: parseFloat(workflow.totalPayments || 0),
|
||||||
|
totalPaymentsIRR: parseFloat(workflow.totalPaymentsIRR || 0),
|
||||||
|
remainingAmount: parseFloat(workflow.remainingAmount || 0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
calculateSummary()
|
||||||
|
clearFilters()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.ErrorMessage)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading workflows:', error)
|
||||||
|
Swal.fire({
|
||||||
|
title: 'خطا',
|
||||||
|
text: 'در بارگذاری دادهها خطایی رخ داد',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateSummary = () => {
|
||||||
|
const workflows = filteredWorkflows.value || []
|
||||||
|
|
||||||
|
const totalAmount = workflows.reduce((sum, w) => sum + w.totalAmount, 0)
|
||||||
|
const totalPayments = workflows.reduce((sum, w) => sum + w.totalPayments, 0)
|
||||||
|
const totalPaymentsIRR = workflows.reduce((sum, w) => sum + w.totalPaymentsIRR, 0)
|
||||||
|
const completedWorkflows = workflows.filter(w => w.status === 'completed').length
|
||||||
|
const inProgressWorkflows = workflows.filter(w => w.status === 'in_progress').length
|
||||||
|
|
||||||
|
summary.value = {
|
||||||
|
totalWorkflows: workflows.length,
|
||||||
|
totalAmount: totalAmount,
|
||||||
|
totalPayments: totalPayments,
|
||||||
|
totalPaymentsIRR: totalPaymentsIRR,
|
||||||
|
remainingAmount: totalAmount - totalPayments,
|
||||||
|
completedWorkflows: completedWorkflows,
|
||||||
|
inProgressWorkflows: inProgressWorkflows,
|
||||||
|
averageCompletionTime: calculateAverageCompletionTime(workflows),
|
||||||
|
growthRate: calculateGrowthRate(workflows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateAverageCompletionTime = (workflows) => {
|
||||||
|
const completedWorkflows = workflows.filter(w => w.status === 'completed' && w.dateSubmit && w.dateCompleted)
|
||||||
|
if (completedWorkflows.length === 0) return 0
|
||||||
|
|
||||||
|
const totalDays = completedWorkflows.reduce((sum, w) => {
|
||||||
|
const startDate = new Date(w.dateSubmit)
|
||||||
|
const endDate = new Date(w.dateCompleted)
|
||||||
|
const diffTime = Math.abs(endDate - startDate)
|
||||||
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||||
|
return sum + diffDays
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return Math.round(totalDays / completedWorkflows.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateGrowthRate = (workflows) => {
|
||||||
|
const currentMonth = dayjs().format('YYYY-MM')
|
||||||
|
const lastMonth = dayjs().subtract(1, 'month').format('YYYY-MM')
|
||||||
|
|
||||||
|
const currentMonthCount = workflows.filter(w => {
|
||||||
|
const workflowDate = dayjs(w.dateSubmit).format('YYYY-MM')
|
||||||
|
return workflowDate === currentMonth
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const lastMonthCount = workflows.filter(w => {
|
||||||
|
const workflowDate = dayjs(w.dateSubmit).format('YYYY-MM')
|
||||||
|
return workflowDate === lastMonth
|
||||||
|
}).length
|
||||||
|
|
||||||
|
if (lastMonthCount === 0) return currentMonthCount > 0 ? 100 : 0
|
||||||
|
return Math.round(((currentMonthCount - lastMonthCount) / lastMonthCount) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
filters.value = {
|
||||||
|
currency: null,
|
||||||
|
dateFrom: null,
|
||||||
|
dateTo: null,
|
||||||
|
supplierSearch: '',
|
||||||
|
countrySearch: '',
|
||||||
|
status: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToExcel = async () => {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'موفق',
|
||||||
|
text: 'فایل اکسل با موفقیت دانلود شد',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'خطا',
|
||||||
|
text: 'در ایجاد فایل اکسل خطایی رخ داد',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportToPDF = async () => {
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'موفق',
|
||||||
|
text: 'فایل PDF با موفقیت دانلود شد',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'خطا',
|
||||||
|
text: 'در ایجاد فایل PDF خطایی رخ داد',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const numericValue = Number(value) || 0
|
||||||
|
return numericValue
|
||||||
|
.toFixed(2)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
const persianDate = dayjs(date).calendar('jalali')
|
||||||
|
const year = persianDate.year()
|
||||||
|
const month = String(persianDate.month() + 1).padStart(2, '0')
|
||||||
|
const day = String(persianDate.date()).padStart(2, '0')
|
||||||
|
return `${year}/${month}/${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWorkflows()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(filters, () => {
|
||||||
|
calculateSummary()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(workflows, () => {
|
||||||
|
calculateSummary()
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-workflow-dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-tab) {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-window) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -36,6 +36,65 @@
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<template v-else-if="workflow">
|
<template v-else-if="workflow">
|
||||||
|
<!-- Financial Summary Card -->
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card class="mb-4" color="primary" variant="tonal">
|
||||||
|
<v-card-title class="text-black">
|
||||||
|
<v-icon class="ml-2">mdi-calculator</v-icon>
|
||||||
|
خلاصه مالی پرونده
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h6 text-black">{{ formatMoney(computedTotalAmount) }}</div>
|
||||||
|
<div class="text-caption text-black">مبلغ کل ({{ workflow.currency }})</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h6 text-black">{{ formatMoney(computedTotalAmountIRR) }}</div>
|
||||||
|
<div class="text-caption text-black">مبلغ کل (ریال)</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h6 text-black">{{ formatMoney(computedTotalPayments) }}</div>
|
||||||
|
<div class="text-caption text-black">مجموع پرداختها ({{ workflow.currency }})</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6" md="3">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-h6" :class="computedRemainingAmount < 0 ? 'text-error' : 'text-black'">
|
||||||
|
{{ formatMoney(computedRemainingAmount) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-black">باقیمانده ({{ workflow.currency }})</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<!-- <v-row class="mt-2">
|
||||||
|
<v-col cols="12">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-caption text-black mb-1">پیشرفت پرداخت</div>
|
||||||
|
<v-progress-linear
|
||||||
|
:model-value="computedPaymentProgress"
|
||||||
|
color="white"
|
||||||
|
height="8"
|
||||||
|
rounded
|
||||||
|
>
|
||||||
|
<template v-slot:default="{ value }">
|
||||||
|
<strong>{{ Math.round(value) }}%</strong>
|
||||||
|
</template>
|
||||||
|
</v-progress-linear>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
</v-row> -->
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
<!-- Basic Info -->
|
<!-- Basic Info -->
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="8">
|
<v-col cols="12" md="8">
|
||||||
|
|
@ -177,15 +236,41 @@
|
||||||
Number(custom.totalCustomsCharges || 0), 0)) }}</div>
|
Number(custom.totalCustomsCharges || 0), 0)) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<!-- <v-col cols="12" md="4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>مجموع پرداختها (ارزی):</strong>
|
||||||
|
<div>{{formatMoney(computedTotalPayments) }} {{ workflow.currency }}</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>مجموع پرداختها (ریال):</strong>
|
||||||
|
<div>{{formatMoney(computedTotalPaymentsIRR) }}</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>باقیمانده (ارزی):</strong>
|
||||||
|
<div :class="computedRemainingAmount < 0 ? 'text-error' : ''">
|
||||||
|
{{formatMoney(computedRemainingAmount) }} {{ workflow.currency }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>باقیمانده (ریال):</strong>
|
||||||
|
<div :class="computedRemainingAmountIRR < 0 ? 'text-error' : ''">
|
||||||
|
{{formatMoney(computedRemainingAmountIRR) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-col> -->
|
||||||
<v-col cols="12" md="4">
|
<v-col cols="12" md="4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<strong>مجموعه پرداخت ها :</strong>
|
<strong>مجموعه پرداخت ها :</strong>
|
||||||
<div class="d-flex align-center gap-2">
|
<div class="d-flex align-center gap-2">
|
||||||
<div>{{formatMoney(workflow.payments?.reduce((total, payment) => Number(total) +
|
<div>{{formatMoney(totalPayments) }} {{ workflow.currency }}</div>
|
||||||
Number(payment.amount || 0), 0)) }} {{ workflow.currency }}</div>
|
|
||||||
<div>|</div>
|
<div>|</div>
|
||||||
<div>{{formatMoney(workflow.payments?.reduce((total, payment) => Number(total) +
|
<div>{{formatMoney(totalPaymentsInIRR) }} ریال</div>
|
||||||
Number(payment.amount * workflow.exchangeRate || 0), 0)) }} ریال</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
@ -425,7 +510,6 @@ const loadStorerooms = async () => {
|
||||||
try {
|
try {
|
||||||
loadingStorerooms.value = true
|
loadingStorerooms.value = true
|
||||||
const res = await axios.get('/api/storeroom/list/active')
|
const res = await axios.get('/api/storeroom/list/active')
|
||||||
// انتظار میرود سرورها آرایهای از انبارها بدهد
|
|
||||||
storerooms.value = Array.isArray(res.data?.data) ? res.data.data : (Array.isArray(res.data) ? res.data : [])
|
storerooms.value = Array.isArray(res.data?.data) ? res.data.data : (Array.isArray(res.data) ? res.data : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
storerooms.value = []
|
storerooms.value = []
|
||||||
|
|
@ -518,7 +602,6 @@ const rules = {
|
||||||
return numeric > 0 || 'مقدار باید مثبت باشد'
|
return numeric > 0 || 'مقدار باید مثبت باشد'
|
||||||
},
|
},
|
||||||
exchangeRateRule: (value) => {
|
exchangeRateRule: (value) => {
|
||||||
// اگر واحد پول IRR باشد، خالی بودن یا 1 بودن نرخ تبدیل را مجاز کن تا فرم معتبر بماند
|
|
||||||
const currentCurrency = editData.value?.currency
|
const currentCurrency = editData.value?.currency
|
||||||
const numeric = parseMoneyInput(value)
|
const numeric = parseMoneyInput(value)
|
||||||
if (currentCurrency === 'IRR') {
|
if (currentCurrency === 'IRR') {
|
||||||
|
|
@ -548,12 +631,105 @@ const computedTotalAmountIRR = computed(() => {
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const computedTotalPayments = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
const exchangeRate =
|
||||||
|
parseFloat(editData.value?.exchangeRate) ||
|
||||||
|
parseFloat(workflow.value?.exchangeRate) ||
|
||||||
|
1
|
||||||
|
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
const amount = parseMoney(payment.amount)
|
||||||
|
if (payment.paymentMode === 'foreign') {
|
||||||
|
return total + amount
|
||||||
|
} else {
|
||||||
|
return total + (amount / exchangeRate)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedTotalPaymentsIRR = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
const exchangeRate =
|
||||||
|
parseFloat(editData.value?.exchangeRate) ||
|
||||||
|
parseFloat(workflow.value?.exchangeRate) ||
|
||||||
|
1
|
||||||
|
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
const amount = parseMoney(payment.amount)
|
||||||
|
if (payment.paymentMode === 'foreign') {
|
||||||
|
return total + (amount * exchangeRate)
|
||||||
|
} else {
|
||||||
|
return total + amount
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedRemainingAmount = computed(() => {
|
||||||
|
return computedTotalAmount.value - computedTotalPayments.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedRemainingAmountIRR = computed(() => {
|
||||||
|
return computedTotalAmountIRR.value - computedTotalPaymentsIRR.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const computedPaymentProgress = computed(() => {
|
||||||
|
if (computedTotalAmount.value <= 0) return 0
|
||||||
|
return (computedTotalPayments.value / computedTotalAmount.value) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalForeignPayments = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
if (payment.paymentMode === 'foreign') {
|
||||||
|
return total + parseMoney(payment.amount)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalLocalPayments = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
if (payment.paymentMode === 'local') {
|
||||||
|
return total + parseMoney(payment.amount)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPayments = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
const exchangeRate = parseFloat(workflow.value?.exchangeRate) || 1
|
||||||
|
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
const amount = parseMoney(payment.amount)
|
||||||
|
if (payment.paymentMode === 'foreign') {
|
||||||
|
return total + (amount * exchangeRate)
|
||||||
|
} else {
|
||||||
|
return total + (amount / exchangeRate)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPaymentsInIRR = computed(() => {
|
||||||
|
if (!workflow.value?.payments) return 0
|
||||||
|
const exchangeRate = parseFloat(workflow.value?.exchangeRate) || 1
|
||||||
|
|
||||||
|
return workflow.value.payments.reduce((total, payment) => {
|
||||||
|
const amount = parseMoney(payment.amount)
|
||||||
|
if (payment.paymentMode === 'foreign') {
|
||||||
|
return total + (amount * exchangeRate)
|
||||||
|
} else {
|
||||||
|
return total + amount
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
// Watch for exchange rate changes to trigger recomputation
|
// Watch for exchange rate changes to trigger recomputation
|
||||||
watch(
|
watch(
|
||||||
() => [editData.value.exchangeRate, editData.value.currency],
|
() => [editData.value.exchangeRate, editData.value.currency],
|
||||||
([exchangeRate, currency]) => {
|
([exchangeRate, currency]) => {
|
||||||
// مبلغ کل ریالی به صورت خودکار محاسبه میشود
|
|
||||||
// نیازی به بهروزرسانی دستی نیست
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
@ -567,9 +743,6 @@ const loadWorkflow = async () => {
|
||||||
if (response.data.Success) {
|
if (response.data.Success) {
|
||||||
workflow.value = response.data.Result
|
workflow.value = response.data.Result
|
||||||
editData.value = { ...response.data.Result }
|
editData.value = { ...response.data.Result }
|
||||||
|
|
||||||
// مبلغ کل به صورت محاسباتی از اقلام محاسبه میشود
|
|
||||||
// نیازی به محاسبه دستی نیست
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.data.ErrorMessage)
|
throw new Error(response.data.ErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
@ -623,7 +796,6 @@ const parseMoneyInput = (val) => {
|
||||||
return Number.isFinite(num) ? parseFloat(num.toFixed(2)) : 0
|
return Number.isFinite(num) ? parseFloat(num.toFixed(2)) : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// دکمه ذخیره تنها زمانی فعال شود که فیلدهای کلیدی معتبر باشند
|
|
||||||
const isFormValidForSave = computed(() => {
|
const isFormValidForSave = computed(() => {
|
||||||
const titleOk = typeof editData.value.title === 'string' && editData.value.title.trim().length >= 3
|
const titleOk = typeof editData.value.title === 'string' && editData.value.title.trim().length >= 3
|
||||||
const supplierOk = typeof editData.value.supplierName === 'string' && editData.value.supplierName.trim().length >= 3
|
const supplierOk = typeof editData.value.supplierName === 'string' && editData.value.supplierName.trim().length >= 3
|
||||||
|
|
@ -651,7 +823,6 @@ const formatNumber = (number) => {
|
||||||
return new Intl.NumberFormat('fa-IR').format(number)
|
return new Intl.NumberFormat('fa-IR').format(number)
|
||||||
}
|
}
|
||||||
|
|
||||||
// نمایش مبالغ با اعشار و با جداکننده ویرگول بین هر سه رقم
|
|
||||||
const formatMoney = (value) => {
|
const formatMoney = (value) => {
|
||||||
const numericValue = Number(value) || 0
|
const numericValue = Number(value) || 0
|
||||||
return numericValue
|
return numericValue
|
||||||
|
|
@ -684,7 +855,6 @@ const formatDate = (date) => {
|
||||||
return new Date(date).toLocaleDateString('fa-IR')
|
return new Date(date).toLocaleDateString('fa-IR')
|
||||||
}
|
}
|
||||||
|
|
||||||
// استایل وضعیت حوالهها
|
|
||||||
const ticketStatusLabel = (status) => {
|
const ticketStatusLabel = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved': return 'تایید شده'
|
case 'approved': return 'تایید شده'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue