update importWorkflow plugin

This commit is contained in:
Gloomy 2025-08-28 11:32:50 +00:00
parent f37aca7c6e
commit 663f5f7173
17 changed files with 4014 additions and 47 deletions

View 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);
}
}

View file

@ -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);
}
} }

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
} }

View file

@ -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",

View 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
})
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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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,
}
} }
], ],
}, },

View file

@ -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>
لیست پروندههای واردات لیست پروندههای واردات

View file

@ -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)

View 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>

View file

@ -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 'تایید شده'