addcustom invoice template plugin

This commit is contained in:
Hesabix 2025-08-09 16:24:45 +00:00
parent e9f2a14a27
commit ba9fe02ce7
18 changed files with 2226 additions and 515 deletions

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250809100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create custom_invoice_template table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE custom_invoice_template (id INT AUTO_INCREMENT NOT NULL, bid_id INT NOT NULL, submitter_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_public TINYINT(1) NOT NULL, code LONGTEXT NOT NULL, INDEX IDX_CUSTOM_INV_TPL_BID (bid_id), INDEX IDX_CUSTOM_INV_TPL_SUBMITTER (submitter_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE custom_invoice_template ADD CONSTRAINT FK_CUSTOM_INV_TPL_BID FOREIGN KEY (bid_id) REFERENCES business (id)');
$this->addSql('ALTER TABLE custom_invoice_template ADD CONSTRAINT FK_CUSTOM_INV_TPL_SUBMITTER FOREIGN KEY (submitter_id) REFERENCES `user` (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_invoice_template DROP FOREIGN KEY FK_CUSTOM_INV_TPL_BID');
$this->addSql('ALTER TABLE custom_invoice_template DROP FOREIGN KEY FK_CUSTOM_INV_TPL_SUBMITTER');
$this->addSql('DROP TABLE custom_invoice_template');
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250809103000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add copy_count to custom_invoice_template';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_invoice_template ADD copy_count INT NOT NULL DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE custom_invoice_template DROP COLUMN copy_count');
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250809112000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add template relations to print_options for sell/buy/rfbuy/rfsell';
}
public function up(Schema $schema): void
{
// this migration is auto-generated, adjust table names if needed
$this->addSql('ALTER TABLE print_options ADD sell_template_id INT DEFAULT NULL, ADD buy_template_id INT DEFAULT NULL, ADD rfbuy_template_id INT DEFAULT NULL, ADD rfsell_template_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE print_options ADD CONSTRAINT FK_PRINT_OPTIONS_SELL_TEMPLATE FOREIGN KEY (sell_template_id) REFERENCES custom_invoice_template (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE print_options ADD CONSTRAINT FK_PRINT_OPTIONS_BUY_TEMPLATE FOREIGN KEY (buy_template_id) REFERENCES custom_invoice_template (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE print_options ADD CONSTRAINT FK_PRINT_OPTIONS_RFBUY_TEMPLATE FOREIGN KEY (rfbuy_template_id) REFERENCES custom_invoice_template (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE print_options ADD CONSTRAINT FK_PRINT_OPTIONS_RFSELL_TEMPLATE FOREIGN KEY (rfsell_template_id) REFERENCES custom_invoice_template (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_PRINT_OPTIONS_SELL_TEMPLATE ON print_options (sell_template_id)');
$this->addSql('CREATE INDEX IDX_PRINT_OPTIONS_BUY_TEMPLATE ON print_options (buy_template_id)');
$this->addSql('CREATE INDEX IDX_PRINT_OPTIONS_RFBUY_TEMPLATE ON print_options (rfbuy_template_id)');
$this->addSql('CREATE INDEX IDX_PRINT_OPTIONS_RFSELL_TEMPLATE ON print_options (rfsell_template_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE print_options DROP FOREIGN KEY FK_PRINT_OPTIONS_SELL_TEMPLATE');
$this->addSql('ALTER TABLE print_options DROP FOREIGN KEY FK_PRINT_OPTIONS_BUY_TEMPLATE');
$this->addSql('ALTER TABLE print_options DROP FOREIGN KEY FK_PRINT_OPTIONS_RFBUY_TEMPLATE');
$this->addSql('ALTER TABLE print_options DROP FOREIGN KEY FK_PRINT_OPTIONS_RFSELL_TEMPLATE');
$this->addSql('DROP INDEX IDX_PRINT_OPTIONS_SELL_TEMPLATE ON print_options');
$this->addSql('DROP INDEX IDX_PRINT_OPTIONS_BUY_TEMPLATE ON print_options');
$this->addSql('DROP INDEX IDX_PRINT_OPTIONS_RFBUY_TEMPLATE ON print_options');
$this->addSql('DROP INDEX IDX_PRINT_OPTIONS_RFSELL_TEMPLATE ON print_options');
$this->addSql('ALTER TABLE print_options DROP sell_template_id, DROP buy_template_id, DROP rfbuy_template_id, DROP rfsell_template_id');
}
}

View file

@ -16,6 +16,8 @@ use App\Entity\Person;
use App\Entity\PrintOptions;
use App\Entity\StoreroomTicket;
use App\Service\Printers;
use App\Entity\CustomInvoiceTemplate;
use App\Service\CustomInvoice\TemplateRenderer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -424,7 +426,7 @@ class BuyController extends AbstractController
}
#[Route('/api/buy/print/invoice', name: 'app_buy_print_invoice')]
public function app_buy_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
public function app_buy_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, TemplateRenderer $renderer): JsonResponse
{
$params = [];
if ($content = $request->getContent()) {
@ -456,53 +458,104 @@ class BuyController extends AbstractController
}
$pdfPid = 0;
if ($params['pdf']) {
$printOptions = [
'bidInfo' => true,
'pays' => true,
'taxInfo' => true,
'discountInfo' => true,
'note' => true,
'paper' => 'A4-L'
];
if (array_key_exists('printOptions', $params)) {
if (array_key_exists('bidInfo', $params['printOptions'])) {
$printOptions['bidInfo'] = $params['printOptions']['bidInfo'];
}
if (array_key_exists('pays', $params['printOptions'])) {
$printOptions['pays'] = $params['printOptions']['pays'];
}
if (array_key_exists('taxInfo', $params['printOptions'])) {
$printOptions['taxInfo'] = $params['printOptions']['taxInfo'];
}
if (array_key_exists('discountInfo', $params['printOptions'])) {
$printOptions['discountInfo'] = $params['printOptions']['discountInfo'];
}
if (array_key_exists('note', $params['printOptions'])) {
$printOptions['note'] = $params['printOptions']['note'];
}
if (array_key_exists('paper', $params['printOptions'])) {
$printOptions['paper'] = $params['printOptions']['paper'];
}
}
$note = '';
// Build print options from defaults and overrides
$printSettings = $entityManager->getRepository(PrintOptions::class)->findOneBy(['bid' => $acc['bid']]);
$defaultOptions = [
'bidInfo' => $printSettings ? $printSettings->isBuyBidInfo() : true,
'pays' => $printSettings ? $printSettings->isBuyPays() : true,
'taxInfo' => $printSettings ? $printSettings->isBuyTaxInfo() : true,
'discountInfo' => $printSettings ? $printSettings->isBuyDiscountInfo() : true,
'note' => $printSettings ? $printSettings->isBuyNote() : true,
'paper' => $printSettings ? $printSettings->getBuyPaper() : 'A4-L',
];
$printOptions = array_merge($defaultOptions, $params['printOptions'] ?? []);
$note = '';
if ($printSettings) {
$note = $printSettings->getBuyNoteString();
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$this->renderView('pdf/printers/buy.html.twig', [
// Build safe context
$rowsArr = array_map(function ($row) {
return [
'commodity' => $row->getCommodity() ? [
'name' => method_exists($row->getCommodity(), 'getName') ? $row->getCommodity()->getName() : null,
'code' => method_exists($row->getCommodity(), 'getCode') ? $row->getCommodity()->getCode() : null,
] : null,
'commodityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray());
$personArr = $person ? [
'name' => $person->getName(),
'mobile' => $person->getMobile(),
'tel' => $person->getTel(),
'address' => $person->getAddress(),
] : null;
$biz = $acc['bid'];
$businessArr = $biz ? [
'name' => method_exists($biz, 'getName') ? $biz->getName() : null,
'tel' => method_exists($biz, 'getTel') ? $biz->getTel() : null,
'mobile' => method_exists($biz, 'getMobile') ? $biz->getMobile() : null,
'address' => method_exists($biz, 'getAddress') ? $biz->getAddress() : null,
'shenasemeli' => method_exists($biz, 'getShenasemeli') ? $biz->getShenasemeli() : null,
'codeeghtesadi' => method_exists($biz, 'getCodeeghtesadi') ? $biz->getCodeeghtesadi() : null,
] : null;
$context = [
'business' => $businessArr,
'doc' => [
'code' => $doc->getCode(),
'date' => method_exists($doc, 'getDate') ? $doc->getDate() : null,
],
'rows' => $rowsArr,
'person' => $personArr,
'discount' => $discount,
'transfer' => $transfer,
'printOptions' => $printOptions,
'note' => $note,
];
// Decide template: custom or default
$html = null;
$selectedTemplate = $printSettings ? $printSettings->getBuyTemplate() : null;
if ($selectedTemplate instanceof CustomInvoiceTemplate) {
$html = $renderer->render($selectedTemplate->getCode() ?? '', $context);
}
if ($html === null) {
$html = $this->renderView('pdf/printers/buy.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'bd' => $row->getBd(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray()),
'person' => $person,
'printInvoice' => $params['printers'],
'discount' => $discount,
'transfer' => $transfer,
'printOptions' => $printOptions,
'note' => $note
]),
]);
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$html,
false,
$printOptions['paper']
);
@ -514,7 +567,18 @@ class BuyController extends AbstractController
$this->renderView('pdf/posPrinters/justBuy.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'bd' => $row->getBd(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray()),
]),
false
);

View file

@ -7,16 +7,14 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\PlugGhestaDoc;
use App\Entity\PlugGhestaItem;
use App\Entity\HesabdariDoc;
use App\Entity\Person;
use App\Service\Access;
use App\Service\Provider;
use App\Service\Printers;
use App\Entity\PrintOptions;
use App\Service\Log;
use App\Entity\Business;
use App\Entity\CustomInvoiceTemplate;
use App\Entity\PrintOptions;
use App\Service\CustomInvoice\TemplateRenderer;
use App\Service\Provider;
use Throwable;
class PlugCustomInvoice extends AbstractController
{
@ -26,5 +24,467 @@ class PlugCustomInvoice extends AbstractController
{
$this->entityManager = $entityManager;
}
#[Route('/api/plugins/custominvoice/template', name: 'plugins_custominvoice_template_create', methods: ['POST'])]
public function createTemplate(Request $request, Access $access, Log $log, TemplateRenderer $renderer): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true) ?? [];
}
$name = $params['name'] ?? null;
$isPublic = (bool)($params['isPublic'] ?? false);
$code = $params['code'] ?? null;
if (!$name || !$code) {
return new JsonResponse(['status' => 'error', 'message' => 'name and code are required'], 400);
}
// Validate template before saving
try {
$renderer->render($code, $this->buildSampleContext($acc['bid']));
} catch (Throwable $e) {
return new JsonResponse([
'status' => 'error',
'message' => 'خطا در قالب: ' . $e->getMessage(),
'hint' => 'اگر قالب مشکل داشته باشد، در زمان چاپِ اسناد ممکن است هیچ خروجی تولید نشود.',
], 400);
}
$template = new CustomInvoiceTemplate();
$template->setBid($acc['bid']);
$template->setSubmitter($this->getUser());
$template->setName($name);
$template->setIsPublic($isPublic);
$template->setCode($code);
$this->entityManager->persist($template);
$this->entityManager->flush();
$log->insert('قالب فاکتور سفارشی', 'ایجاد قالب: ' . $name, $this->getUser(), $acc['bid']);
return $this->json([
'status' => 'ok',
'data' => [
'id' => $template->getId(),
'name' => $template->getName(),
'isPublic' => $template->isPublic(),
'code' => $template->getCode(),
],
]);
}
#[Route('/api/plugins/custominvoice/template/{id<\d+>}', name: 'plugins_custominvoice_template_get', methods: ['GET'])]
public function getTemplate(int $id, Access $access): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$template = $this->entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid'],
]);
if (!$template) {
return new JsonResponse(['status' => 'error', 'message' => 'Template not found'], 404);
}
return $this->json([
'status' => 'ok',
'data' => [
'id' => $template->getId(),
'name' => $template->getName(),
'isPublic' => $template->isPublic(),
'code' => $template->getCode(),
],
]);
}
#[Route('/api/plugins/custominvoice/template/{id<\d+>}', name: 'plugins_custominvoice_template_update', methods: ['PUT','POST'])]
public function updateTemplate(int $id, Request $request, Access $access, Log $log, TemplateRenderer $renderer): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$template = $this->entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid'],
]);
if (!$template) {
return new JsonResponse(['status' => 'error', 'message' => 'Template not found'], 404);
}
$params = [];
if ($content = $request->getContent()) {
$params = json_decode($content, true) ?? [];
}
if (array_key_exists('name', $params)) {
$template->setName((string)$params['name']);
}
if (array_key_exists('isPublic', $params)) {
$template->setIsPublic((bool)$params['isPublic']);
}
if (array_key_exists('code', $params)) {
$newCode = (string)$params['code'];
try {
$renderer->render($newCode, $this->buildSampleContext($acc['bid']));
} catch (Throwable $e) {
return new JsonResponse([
'status' => 'error',
'message' => 'خطا در قالب: ' . $e->getMessage(),
'hint' => 'اگر قالب مشکل داشته باشد، در زمان چاپِ اسناد ممکن است هیچ خروجی تولید نشود.',
], 400);
}
$template->setCode($newCode);
}
$this->entityManager->persist($template);
$this->entityManager->flush();
$log->insert('قالب فاکتور سفارشی', 'ویرایش قالب: ' . $template->getName(), $this->getUser(), $acc['bid']);
return $this->json([
'status' => 'ok',
'data' => [
'id' => $template->getId(),
'name' => $template->getName(),
'isPublic' => $template->isPublic(),
'code' => $template->getCode(),
],
]);
}
#[Route('/api/plugins/custominvoice/template/preview', name: 'plugins_custominvoice_template_preview', methods: ['POST'])]
public function preview(Request $request, Access $access, TemplateRenderer $renderer, Provider $provider): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$params = json_decode($request->getContent(), true) ?? [];
$code = (string)($params['code'] ?? '');
$paper = (string)($params['paper'] ?? 'A4-L');
if ($code === '') {
return new JsonResponse(['status' => 'error', 'message' => 'code is required'], 400);
}
try {
$context = $this->buildSampleContext($acc['bid']);
$html = $renderer->render($code, $context);
} catch (Throwable $e) {
return new JsonResponse([
'status' => 'error',
'message' => 'خطا در قالب: ' . $e->getMessage(),
], 400);
}
$pid = $provider->createPrint($acc['bid'], $this->getUser(), $html, false, $paper);
$printUrl = $this->generateUrl('app_front_print', ['id' => $pid]);
return $this->json([
'status' => 'ok',
'html' => $html,
'printId' => $pid,
'printUrl' => $printUrl,
'warning' => 'این یک پیش فیمایش است. اگر قالب مشکل داشته باشد، هنگام چاپ اسناد ممکن است خروجی تولید نشود.',
]);
}
private function buildSampleContext(Business $bid): array
{
$now = time();
return [
'accountStatus' => [
'value' => 1250000,
'label' => 'بدهکار',
],
'bid' => [
'id' => method_exists($bid, 'getId') ? $bid->getId() : 0,
'legalName' => 'شرکت نمونه',
'shenasemeli' => '1234567890',
'shomaresabt' => '987654',
'codeeghtesadi' => '123456789012',
'tel' => '021-12345678',
'postalcode' => '1234567890',
'ostan' => 'تهران',
'shahrestan' => 'تهران',
'address' => 'خیابان نمونه، کوچه یک، پلاک 1',
],
'business' => [
'name' => 'کسب 4 کار نمونه',
'tel' => '021-12345678',
'mobile' => '09120000000',
'address' => 'تهران، ایران',
'shenasemeli' => '1234567890',
'codeeghtesadi' => '123456789012',
'id' => method_exists($bid, 'getId') ? $bid->getId() : 0,
],
'doc' => [
'code' => 'INV-0001',
'date' => date('Y/m/d', $now),
'taxPercent' => 9,
'discountPercent' => 5,
'discountType' => 'amount',
'amount' => 2350000,
'relatedDocs' => [
['date' => date('Y/m/d', $now), 'amount' => 500000, 'des' => 'پرداخت شماره 1'],
],
'money' => [
'shortName' => 'ریال',
],
],
'rows' => [
[
'commodity' => [
'code' => 'P-001',
'name' => 'کالای A',
'unit' => ['name' => 'عدد'],
],
'commodityCount' => 2,
'des' => 'شرح آیتم اول',
'bs' => 1000000,
'tax' => 90000,
'discount' => 50000,
'showPercentDiscount' => false,
'discountPercent' => 0,
],
[
'commodity' => [
'code' => 'S-002',
'name' => 'خدمت B',
'unit' => ['name' => 'ساعت'],
],
'commodityCount' => 3,
'des' => 'شرح آیتم دوم',
'bs' => 1350000,
'tax' => 121500,
'discount' => 0,
'showPercentDiscount' => true,
'discountPercent' => 10,
],
],
'person' => [
'prelabel' => ['label' => 'جناب'],
'nikename' => 'مشتری نمونه',
'shenasemeli' => '1234567890',
'sabt' => '112233',
'codeeghtesadi' => '556677889900',
'tel' => '021-88888888',
'postalcode' => '1234567890',
'ostan' => 'تهران',
'shahr' => 'تهران',
'address' => 'خیابان مشتری، پلاک 10',
],
'discount' => 50000,
'transfer' => 20000,
'printOptions' => [
'invoiceIndex' => true,
'discountInfo' => true,
'taxInfo' => true,
'pays' => true,
'note' => true,
'businessStamp' => true,
'paper' => 'A4-L',
],
'note' => '<b>یادداشت آزمایشی:</b> این فقط پیش فیمایش است.',
];
}
#[Route('/api/plugins/custominvoice/template/{id<\d+>}/copy', name: 'plugins_custominvoice_template_copy', methods: ['POST'])]
public function copyTemplate(int $id, Request $request, Access $access, Log $log): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$source = $this->entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => $id,
]);
if (!$source || !$source->isPublic()) {
return new JsonResponse(['status' => 'error', 'message' => 'Source template not found or not public'], 404);
}
$new = new CustomInvoiceTemplate();
$new->setBid($acc['bid']);
$new->setSubmitter($this->getUser());
$new->setName($source->getName());
$new->setIsPublic(false);
$new->setCode($source->getCode());
$this->entityManager->persist($new);
// increment source copy count
$source->setCopyCount($source->getCopyCount() + 1);
$this->entityManager->persist($source);
$this->entityManager->flush();
$log->insert('قالب فاکتور سفارشی', 'کپی از قالب عمومی: ' . $source->getName(), $this->getUser(), $acc['bid']);
return $this->json([
'status' => 'ok',
'data' => [
'id' => $new->getId(),
]
]);
}
#[Route('/api/plugins/custominvoice/template/list', name: 'plugins_custominvoice_template_list', methods: ['POST'])]
public function listTemplates(Request $request, Access $access): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$params = json_decode($request->getContent(), true) ?? [];
$page = max(1, (int)($params['page'] ?? 1));
$limit = max(1, min(100, (int)($params['itemsPerPage'] ?? 10)));
$search = trim((string)($params['search'] ?? ''));
$sortBy = $params['sortBy'] ?? [];
$scope = strtolower((string)($params['scope'] ?? 'business'));
if (!in_array($scope, ['business', 'public'], true)) {
$scope = 'business';
}
$qb = $this->entityManager->getRepository(CustomInvoiceTemplate::class)
->createQueryBuilder('t')
->leftJoin('t.submitter', 'u')
->addSelect('u');
if ($scope === 'business') {
$qb->andWhere('t.bid = :bid')
->setParameter('bid', $acc['bid']);
} else {
$qb->andWhere('t.isPublic = 1');
}
if ($search !== '') {
$qb->andWhere(
$qb->expr()->orX(
't.name LIKE :search',
't.code LIKE :search',
'u.fullName LIKE :search',
'u.email LIKE :search'
)
)->setParameter('search', '%' . $search . '%');
}
if (is_array($sortBy) && count($sortBy) > 0) {
$firstSort = $sortBy[0];
$key = in_array($firstSort['key'] ?? 'id', ['id', 'name', 'isPublic', 'copyCount']) ? $firstSort['key'] : 'id';
$order = strtoupper($firstSort['order'] ?? 'DESC');
$order = $order === 'ASC' ? 'ASC' : 'DESC';
$qb->orderBy('t.' . $key, $order);
} else {
// default: for public scope, popular first; else by id desc
if ($scope === 'public') {
$qb->orderBy('t.copyCount', 'DESC')->addOrderBy('t.id', 'DESC');
} else {
$qb->orderBy('t.id', 'DESC');
}
}
$total = (int)(clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
$items = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
$currentBidId = method_exists($acc['bid'], 'getId') ? $acc['bid']->getId() : null;
$data = array_map(function (CustomInvoiceTemplate $t) use ($currentBidId) {
$ownedByMe = $currentBidId !== null && $t->getBid() && $t->getBid()->getId() === $currentBidId;
return [
'id' => $t->getId(),
'name' => $t->getName(),
'isPublic' => $t->isPublic(),
'code' => $t->getCode(),
'ownedByMe' => $ownedByMe,
'submitter' => $t->getSubmitter() ? [
'id' => $t->getSubmitter()->getId(),
'fullName' => $t->getSubmitter()->getFullName(),
'email' => $t->getSubmitter()->getEmail(),
] : null,
'copyCount' => $t->getCopyCount(),
'popular' => $t->getCopyCount() > 10,
];
}, $items);
return $this->json([
'items' => $data,
'total' => $total,
'page' => $page,
'itemsPerPage' => $limit,
]);
}
#[Route('/api/plugins/custominvoice/template/{id<\d+>}', name: 'plugins_custominvoice_template_delete', methods: ['DELETE'])]
public function deleteTemplate(int $id, Access $access, Log $log): JsonResponse
{
$acc = $access->hasRole('settings');
if (!$acc) {
throw $this->createAccessDeniedException();
}
$template = $this->entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => $id,
'bid' => $acc['bid'],
]);
if (!$template) {
return new JsonResponse(['status' => 'error', 'message' => 'Template not found'], 404);
}
// Before delete, nullify references in PrintOptions for this business
$printOptionsList = $this->entityManager->getRepository(PrintOptions::class)->findBy([
'bid' => $acc['bid'],
]);
foreach ($printOptionsList as $opt) {
$changed = false;
if ($opt->getSellTemplate() && $opt->getSellTemplate()->getId() === $template->getId()) {
$opt->setSellTemplate(null);
$changed = true;
}
if ($opt->getBuyTemplate() && $opt->getBuyTemplate()->getId() === $template->getId()) {
$opt->setBuyTemplate(null);
$changed = true;
}
if ($opt->getRfbuyTemplate() && $opt->getRfbuyTemplate()->getId() === $template->getId()) {
$opt->setRfbuyTemplate(null);
$changed = true;
}
if ($opt->getRfsellTemplate() && $opt->getRfsellTemplate()->getId() === $template->getId()) {
$opt->setRfsellTemplate(null);
$changed = true;
}
if ($changed) {
$this->entityManager->persist($opt);
}
}
// Apply changes before deleting the template to avoid FK issues when migrations not applied
$this->entityManager->flush();
$name = $template->getName();
$this->entityManager->remove($template);
$this->entityManager->flush();
$log->insert('قالب فاکتور سفارشی', 'حذف قالب: ' . $name, $this->getUser(), $acc['bid']);
return $this->json(['status' => 'ok']);
}
}

View file

@ -6,6 +6,7 @@ use App\Entity\Printer;
use App\Entity\PrinterQueue;
use App\Entity\PrintItem;
use App\Entity\PrintOptions;
use App\Entity\CustomInvoiceTemplate;
use App\Service\Access;
use App\Service\Explore;
use App\Service\Extractor;
@ -59,6 +60,7 @@ class PrintersController extends AbstractController
$temp['sell']['paper'] = $settings->getSellPaper();
$temp['sell']['businessStamp'] = $settings->isSellBusinessStamp();
$temp['sell']['invoiceIndex'] = $settings->isSellInvoiceIndex();
$temp['sell']['templateId'] = $settings->getSellTemplate() ? $settings->getSellTemplate()->getId() : null;
if (!$temp['sell']['paper']) {
$temp['sell']['paper'] = 'A4-L';
}
@ -71,6 +73,7 @@ class PrintersController extends AbstractController
$temp['buy']['noteString'] = $settings->getBuyNoteString();
$temp['buy']['pays'] = $settings->isBuyPays();
$temp['buy']['paper'] = $settings->getBuyPaper();
$temp['buy']['templateId'] = $settings->getBuyTemplate() ? $settings->getBuyTemplate()->getId() : null;
if (!$temp['buy']['paper']) {
$temp['buy']['paper'] = 'A4-L';
}
@ -83,6 +86,7 @@ class PrintersController extends AbstractController
$temp['rfbuy']['noteString'] = $settings->getRfbuyNoteString();
$temp['rfbuy']['pays'] = $settings->isRfbuyPays();
$temp['rfbuy']['paper'] = $settings->getRfbuyPaper();
$temp['rfbuy']['templateId'] = $settings->getRfbuyTemplate() ? $settings->getRfbuyTemplate()->getId() : null;
if (!$temp['rfbuy']['paper']) {
$temp['rfbuy']['paper'] = 'A4-L';
}
@ -95,6 +99,7 @@ class PrintersController extends AbstractController
$temp['rfsell']['noteString'] = $settings->getRfsellNoteString();
$temp['rfsell']['pays'] = $settings->isRfsellPays();
$temp['rfsell']['paper'] = $settings->getRfsellPaper();
$temp['rfsell']['templateId'] = $settings->getRfsellTemplate() ? $settings->getRfsellTemplate()->getId() : null;
$temp['fastsell']['cashdeskTicket'] = $settings->isFastsellCashdeskTicket();
$temp['fastsell']['invoice'] = $settings->isFastsellInvoice();
@ -139,6 +144,19 @@ class PrintersController extends AbstractController
$settings->setSellPaper($params['sell']['paper']);
$settings->setSellBusinessStamp($params['sell']['businessStamp'] ?? false);
$settings->setSellInvoiceIndex($params['sell']['invoiceIndex'] ?? false);
// Resolve templates by ID and ownership; update only if key exists
if (array_key_exists('sell', $params) && array_key_exists('templateId', $params['sell'])) {
$sellT = null;
if ($params['sell']['templateId']) {
$sellT = $entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => (int)$params['sell']['templateId'],
'bid' => $acc['bid'],
]);
}
$settings->setSellTemplate($sellT);
}
if ($params['buy']['bidInfo'] == null) {
$settings->setBuyBidInfo(false);
} else {
@ -152,6 +170,17 @@ class PrintersController extends AbstractController
$settings->setBuyPays($params['buy']['pays'] ?? false);
$settings->setBuyPaper($params['buy']['paper']);
if (array_key_exists('buy', $params) && array_key_exists('templateId', $params['buy'])) {
$buyT = null;
if ($params['buy']['templateId']) {
$buyT = $entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => (int)$params['buy']['templateId'],
'bid' => $acc['bid'],
]);
}
$settings->setBuyTemplate($buyT);
}
$settings->setRfbuyBidInfo($params['rfbuy']['bidInfo'] ?? false);
$settings->setRfbuyTaxInfo($params['rfbuy']['taxInfo'] ?? false);
$settings->setRfbuyDiscountInfo($params['rfbuy']['discountInfo'] ?? false);
@ -160,6 +189,17 @@ class PrintersController extends AbstractController
$settings->setRfbuyPays($params['rfbuy']['pays'] ?? false);
$settings->setRfbuyPaper($params['rfbuy']['paper']);
if (array_key_exists('rfbuy', $params) && array_key_exists('templateId', $params['rfbuy'])) {
$rfbuyT = null;
if ($params['rfbuy']['templateId']) {
$rfbuyT = $entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => (int)$params['rfbuy']['templateId'],
'bid' => $acc['bid'],
]);
}
$settings->setRfbuyTemplate($rfbuyT);
}
$settings->setRfsellBidInfo($params['rfsell']['bidInfo'] ?? false);
$settings->setRfsellTaxInfo($params['rfsell']['taxInfo'] ?? false);
$settings->setRfsellDiscountInfo($params['rfsell']['discountInfo'] ?? false);
@ -168,6 +208,17 @@ class PrintersController extends AbstractController
$settings->setRfsellPays($params['rfsell']['pays'] ?? false);
$settings->setRfSellPaper($params['rfsell']['paper']);
if (array_key_exists('rfsell', $params) && array_key_exists('templateId', $params['rfsell'])) {
$rfsellT = null;
if ($params['rfsell']['templateId']) {
$rfsellT = $entityManager->getRepository(CustomInvoiceTemplate::class)->findOneBy([
'id' => (int)$params['rfsell']['templateId'],
'bid' => $acc['bid'],
]);
}
$settings->setRfsellTemplate($rfsellT);
}
$settings->setRepserviceNoteString($params['repservice']['noteString']);
$settings->setRepServicePaper($params['repservice']['paper']);

View file

@ -14,6 +14,8 @@ use App\Entity\HesabdariTable;
use App\Entity\InvoiceType;
use App\Entity\Person;
use App\Entity\PrintOptions;
use App\Entity\CustomInvoiceTemplate;
use App\Service\CustomInvoice\TemplateRenderer;
use App\Entity\StoreroomTicket;
use App\Service\Printers;
use Doctrine\ORM\EntityManagerInterface;
@ -386,7 +388,7 @@ class RfbuyController extends AbstractController
}
#[Route('/api/rfbuy/print/invoice', name: 'app_rfbuy_print_invoice')]
public function app_rfbuy_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
public function app_rfbuy_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, TemplateRenderer $renderer): JsonResponse
{
$params = [];
if ($content = $request->getContent()) {
@ -447,20 +449,86 @@ class RfbuyController extends AbstractController
$note = '';
$printSettings = $entityManager->getRepository(PrintOptions::class)->findOneBy(['bid'=>$acc['bid']]);
if($printSettings){$note = $printSettings->getRfbuyNoteString();}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$this->renderView('pdf/printers/rfbuy.html.twig', [
// Build safe context
$rowsArr = array_map(function ($row) {
return [
'commodity' => $row->getCommodity() ? [
'name' => method_exists($row->getCommodity(), 'getName') ? $row->getCommodity()->getName() : null,
'code' => method_exists($row->getCommodity(), 'getCode') ? $row->getCommodity()->getCode() : null,
] : null,
'commodityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray());
$personArr = $person ? [
'name' => $person->getName(),
'mobile' => $person->getMobile(),
'tel' => $person->getTel(),
'address' => $person->getAddress(),
] : null;
$biz = $acc['bid'];
$businessArr = $biz ? [
'name' => method_exists($biz, 'getName') ? $biz->getName() : null,
'tel' => method_exists($biz, 'getTel') ? $biz->getTel() : null,
'mobile' => method_exists($biz, 'getMobile') ? $biz->getMobile() : null,
'address' => method_exists($biz, 'getAddress') ? $biz->getAddress() : null,
'shenasemeli' => method_exists($biz, 'getShenasemeli') ? $biz->getShenasemeli() : null,
'codeeghtesadi' => method_exists($biz, 'getCodeeghtesadi') ? $biz->getCodeeghtesadi() : null,
] : null;
$context = [
'business' => $businessArr,
'doc' => [
'code' => $doc->getCode(),
'date' => method_exists($doc, 'getDate') ? $doc->getDate() : null,
],
'rows' => $rowsArr,
'person' => $personArr,
'discount' => $discount,
'transfer' => $transfer,
'printOptions'=> $printOptions,
'note'=> $note
];
$html = null;
$selectedTemplate = $printSettings ? $printSettings->getRfbuyTemplate() : null;
if ($selectedTemplate instanceof CustomInvoiceTemplate) {
$html = $renderer->render($selectedTemplate->getCode() ?? '', $context);
}
if ($html === null) {
$html = $this->renderView('pdf/printers/rfbuy.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'bd' => $row->getBd(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray()),
'person' => $person,
'printInvoice' => $params['printers'],
'discount' => $discount,
'transfer' => $transfer,
'printOptions'=> $printOptions,
'note'=> $note
]),
]);
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$html,
false,
$printOptions['paper']
);
@ -472,7 +540,18 @@ class RfbuyController extends AbstractController
$this->renderView('pdf/posPrinters/justRfbuy.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'bd' => $row->getBd(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray()),
]),
false
);

View file

@ -14,6 +14,8 @@ use App\Entity\HesabdariTable;
use App\Entity\InvoiceType;
use App\Entity\Person;
use App\Entity\PrintOptions;
use App\Entity\CustomInvoiceTemplate;
use App\Service\CustomInvoice\TemplateRenderer;
use App\Entity\StoreroomTicket;
use App\Service\Printers;
use Doctrine\ORM\EntityManagerInterface;
@ -408,7 +410,7 @@ class RfsellController extends AbstractController
}
#[Route('/api/rfsell/print/invoice', name: 'app_rfsell_print_invoice')]
public function app_rfsell_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
public function app_rfsell_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, TemplateRenderer $renderer): JsonResponse
{
$params = [];
if ($content = $request->getContent()) {
@ -469,20 +471,87 @@ class RfsellController extends AbstractController
$note = '';
$printSettings = $entityManager->getRepository(PrintOptions::class)->findOneBy(['bid'=>$acc['bid']]);
if($printSettings){$note = $printSettings->getRfsellNoteString();}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$this->renderView('pdf/printers/rfsell.html.twig', [
// Build safe context
$rowsArr = array_map(function ($row) {
return [
'commodity' => $row->getCommodity() ? [
'name' => method_exists($row->getCommodity(), 'getName') ? $row->getCommodity()->getName() : null,
'code' => method_exists($row->getCommodity(), 'getCode') ? $row->getCommodity()->getCode() : null,
] : null,
'commodityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
];
}, $doc->getHesabdariRows()->toArray());
$personArr = $person ? [
'name' => $person->getName(),
'mobile' => $person->getMobile(),
'tel' => $person->getTel(),
'address' => $person->getAddress(),
] : null;
$biz = $acc['bid'];
$businessArr = $biz ? [
'name' => method_exists($biz, 'getName') ? $biz->getName() : null,
'tel' => method_exists($biz, 'getTel') ? $biz->getTel() : null,
'mobile' => method_exists($biz, 'getMobile') ? $biz->getMobile() : null,
'address' => method_exists($biz, 'getAddress') ? $biz->getAddress() : null,
'shenasemeli' => method_exists($biz, 'getShenasemeli') ? $biz->getShenasemeli() : null,
'codeeghtesadi' => method_exists($biz, 'getCodeeghtesadi') ? $biz->getCodeeghtesadi() : null,
] : null;
$context = [
'business' => $businessArr,
'doc' => [
'code' => $doc->getCode(),
'date' => method_exists($doc, 'getDate') ? $doc->getDate() : null,
],
'rows' => $rowsArr,
'person' => $personArr,
'discount' => $discount,
'transfer' => $transfer,
'printOptions'=> $printOptions,
'note'=> $note
];
$html = null;
$selectedTemplate = $printSettings ? $printSettings->getRfsellTemplate() : null;
if ($selectedTemplate instanceof CustomInvoiceTemplate) {
$html = $renderer->render($selectedTemplate->getCode() ?? '', $context);
}
if ($html === null) {
$html = $this->renderView('pdf/printers/rfsell.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
'showPercentDiscount' => $row->getDiscountType() === 'percent',
'discountPercent' => $row->getDiscountPercent(),
];
}, $doc->getHesabdariRows()->toArray()),
'person' => $person,
'printInvoice' => $params['printers'],
'discount' => $discount,
'transfer' => $transfer,
'printOptions'=> $printOptions,
'note'=> $note
]),
]);
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$html,
false,
$printOptions['paper']
);
@ -494,8 +563,22 @@ class RfsellController extends AbstractController
$this->renderView('pdf/posPrinters/justSell.html.twig', [
'bid' => $acc['bid'],
'doc' => $doc,
'rows' => $doc->getHesabdariRows(),
]),
'rows' => array_map(function ($row) {
return [
'commodity' => $row->getCommodity(),
'commodityCount' => $row->getCommdityCount(),
'commdityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
'showPercentDiscount' => $row->getDiscountType() === 'percent',
'discountPercent' => $row->getDiscountPercent(),
];
}, $doc->getHesabdariRows()->toArray()),
'discount' => $discount,
'transfer' => $transfer,
]),
false
);
$printers->addFile($pid, $acc, "fastSellInvoice");

View file

@ -8,7 +8,6 @@ use App\Service\Log;
use App\Service\Access;
use App\Service\Explore;
use App\Entity\Commodity;
use App\Service\PluginService;
use App\Service\Provider;
use App\Service\Extractor;
use App\Entity\HesabdariDoc;
@ -31,6 +30,9 @@ use App\Entity\BankAccount;
use App\Entity\Cashdesk;
use App\Entity\Salary;
use App\Entity\Year;
use App\Entity\CustomInvoiceTemplate;
use App\Service\CustomInvoice\TemplateRenderer;
use App\Service\PluginService;
class SellController extends AbstractController
{
@ -710,7 +712,7 @@ class SellController extends AbstractController
}
#[Route('/api/sell/print/invoice', name: 'app_sell_print_invoice')]
public function app_sell_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager): JsonResponse
public function app_sell_print_invoice(Printers $printers, Provider $provider, Request $request, Access $access, Log $log, EntityManagerInterface $entityManager, PluginService $pluginService, TemplateRenderer $renderer): JsonResponse
{
$acc = $access->hasRole('sell');
if (!$acc)
@ -781,10 +783,76 @@ class SellController extends AbstractController
if ($printSettings) {
$note = $printSettings->getSellNoteString();
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$this->renderView('pdf/printers/sell.html.twig', [
// Build safe context data for rendering
$rowsArr = array_map(function ($row) {
return [
'commodity' => $row->getCommodity() ? [
'name' => method_exists($row->getCommodity(), 'getName') ? $row->getCommodity()->getName() : null,
'code' => method_exists($row->getCommodity(), 'getCode') ? $row->getCommodity()->getCode() : null,
] : null,
'commodityCount' => $row->getCommdityCount(),
'des' => $row->getDes(),
'bs' => $row->getBs(),
'tax' => $row->getTax(),
'discount' => $row->getDiscount(),
'showPercentDiscount' => $row->getDiscountType() === 'percent',
'discountPercent' => $row->getDiscountPercent()
];
}, $doc->getHesabdariRows()->toArray());
$personArr = $person ? [
'name' => $person->getName(),
'mobile' => $person->getMobile(),
'tel' => $person->getTel(),
'address' => $person->getAddress(),
] : null;
$biz = $acc['bid'];
$businessArr = $biz ? [
'name' => method_exists($biz, 'getName') ? $biz->getName() : null,
'tel' => method_exists($biz, 'getTel') ? $biz->getTel() : null,
'mobile' => method_exists($biz, 'getMobile') ? $biz->getMobile() : null,
'address' => method_exists($biz, 'getAddress') ? $biz->getAddress() : null,
'shenasemeli' => method_exists($biz, 'getShenasemeli') ? $biz->getShenasemeli() : null,
'codeeghtesadi' => method_exists($biz, 'getCodeeghtesadi') ? $biz->getCodeeghtesadi() : null,
'id' => method_exists($biz, 'getId') ? $biz->getId() : null,
] : null;
$context = [
'accountStatus' => $accountStatus,
'business' => $businessArr,
'bid' => $businessArr,
'doc' => [
'code' => $doc->getCode(),
'date' => method_exists($doc, 'getDate') ? $doc->getDate() : null,
'taxPercent' => method_exists($doc, 'getTaxPercent') ? $doc->getTaxPercent() : null,
'discountPercent' => $doc->getDiscountPercent(),
'discountType' => $doc->getDiscountType(),
'money' => [
'shortName' => method_exists($doc, 'getMoney') && $doc->getMoney() && method_exists($doc->getMoney(), 'getShortName') ? $doc->getMoney()->getShortName() : null,
],
],
'rows' => $rowsArr,
'person' => $personArr,
'discount' => $discount,
'transfer' => $transfer,
'printOptions' => $printOptions,
'note' => $note,
];
// Decide template: custom or default
$html = null;
$isCustomInvoiceActive = $pluginService->isActive('custominvoice', $acc['bid']);
$selectedTemplate = $printSettings ? $printSettings->getSellTemplate() : null;
if ($isCustomInvoiceActive && $selectedTemplate instanceof CustomInvoiceTemplate) {
$html = $renderer->render($selectedTemplate->getCode() ?? '', $context);
}
if ($html === null) {
// fallback to default Twig template
$html = $this->renderView('pdf/printers/sell.html.twig', [
'accountStatus' => $accountStatus,
'bid' => $acc['bid'],
'doc' => $doc,
@ -808,7 +876,13 @@ class SellController extends AbstractController
'note' => $note,
'showPercentDiscount' => $doc->getDiscountType() === 'percent',
'discountPercent' => $doc->getDiscountPercent()
]),
]);
}
$pdfPid = $provider->createPrint(
$acc['bid'],
$this->getUser(),
$html,
false,
$printOptions['paper']
);

View file

@ -0,0 +1,112 @@
<?php
namespace App\Entity;
use App\Repository\CustomInvoiceTemplateRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CustomInvoiceTemplateRepository::class)]
class CustomInvoiceTemplate
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?Business $bid = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $submitter = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'boolean')]
private bool $isPublic = false;
#[ORM\Column(type: Types::TEXT)]
private ?string $code = null;
#[ORM\Column(type: 'integer')]
private int $copyCount = 0;
public function getId(): ?int
{
return $this->id;
}
public function getBid(): ?Business
{
return $this->bid;
}
public function setBid(?Business $bid): static
{
$this->bid = $bid;
return $this;
}
public function getSubmitter(): ?User
{
return $this->submitter;
}
public function setSubmitter(?User $submitter): static
{
$this->submitter = $submitter;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function isPublic(): bool
{
return $this->isPublic;
}
public function setIsPublic(bool $isPublic): static
{
$this->isPublic = $isPublic;
return $this;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getCopyCount(): int
{
return $this->copyCount;
}
public function setCopyCount(int $copyCount): static
{
$this->copyCount = $copyCount;
return $this;
}
}

View file

@ -132,6 +132,22 @@ class PrintOptions
#[ORM\Column(nullable: true)]
private ?bool $sellBusinessStamp = null;
#[ORM\ManyToOne(targetEntity: CustomInvoiceTemplate::class)]
#[ORM\JoinColumn(name: 'sell_template_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CustomInvoiceTemplate $sellTemplate = null;
#[ORM\ManyToOne(targetEntity: CustomInvoiceTemplate::class)]
#[ORM\JoinColumn(name: 'buy_template_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CustomInvoiceTemplate $buyTemplate = null;
#[ORM\ManyToOne(targetEntity: CustomInvoiceTemplate::class)]
#[ORM\JoinColumn(name: 'rfbuy_template_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CustomInvoiceTemplate $rfbuyTemplate = null;
#[ORM\ManyToOne(targetEntity: CustomInvoiceTemplate::class)]
#[ORM\JoinColumn(name: 'rfsell_template_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?CustomInvoiceTemplate $rfsellTemplate = null;
public function getId(): ?int
{
return $this->id;
@ -602,4 +618,48 @@ class PrintOptions
$this->sellBusinessStamp = $sellBusinessStamp;
return $this;
}
public function getSellTemplate(): ?CustomInvoiceTemplate
{
return $this->sellTemplate;
}
public function setSellTemplate(?CustomInvoiceTemplate $sellTemplate): self
{
$this->sellTemplate = $sellTemplate;
return $this;
}
public function getBuyTemplate(): ?CustomInvoiceTemplate
{
return $this->buyTemplate;
}
public function setBuyTemplate(?CustomInvoiceTemplate $buyTemplate): self
{
$this->buyTemplate = $buyTemplate;
return $this;
}
public function getRfbuyTemplate(): ?CustomInvoiceTemplate
{
return $this->rfbuyTemplate;
}
public function setRfbuyTemplate(?CustomInvoiceTemplate $rfbuyTemplate): self
{
$this->rfbuyTemplate = $rfbuyTemplate;
return $this;
}
public function getRfsellTemplate(): ?CustomInvoiceTemplate
{
return $this->rfsellTemplate;
}
public function setRfsellTemplate(?CustomInvoiceTemplate $rfsellTemplate): self
{
$this->rfsellTemplate = $rfsellTemplate;
return $this;
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Repository;
use App\Entity\CustomInvoiceTemplate;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CustomInvoiceTemplate>
*/
class CustomInvoiceTemplateRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CustomInvoiceTemplate::class);
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Service\CustomInvoice;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;
use Symfony\Bridge\Twig\Extension\RoutingExtension;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TemplateRenderer
{
private UrlGeneratorInterface $urlGenerator;
public function __construct(UrlGeneratorInterface $urlGenerator)
{
$this->urlGenerator = $urlGenerator;
}
/**
* Render user-provided template code with sandboxed Twig and a safe context.
* Only a small set of tags and filters are allowed; no functions, properties or methods.
*/
public function render(string $code, array $context = []): string
{
$loader = new ArrayLoader(['tpl' => $code]);
$twig = new Environment($loader, [
'autoescape' => 'html',
'cache' => false,
'strict_variables' => false,
]);
// Allow generating routes inside templates
$twig->addExtension(new RoutingExtension($this->urlGenerator));
$allowedTags = ['if', 'for', 'set'];
$allowedFilters = ['escape', 'upper', 'lower', 'number_format', 'join', 'round', 'length', 'raw'];
$allowedMethods = [];
$allowedProperties = [];
$allowedFunctions = ['path', 'url'];
$policy = new SecurityPolicy($allowedTags, $allowedFilters, $allowedMethods, $allowedProperties, $allowedFunctions);
$twig->addExtension(new SandboxExtension($policy, true));
// Ensure all objects are converted to arrays to avoid method/property access
$safeContext = $this->deepNormalize($context);
return $twig->render('tpl', $safeContext);
}
private function deepNormalize(mixed $value): mixed
{
if (is_array($value)) {
$normalized = [];
foreach ($value as $k => $v) {
$normalized[$k] = $this->deepNormalize($v);
}
return $normalized;
}
if (is_object($value)) {
// Expose nothing from raw objects
return [];
}
return $value;
}
}

View file

@ -4,27 +4,27 @@
<head>
<style>
.center {
text-align: center;
text-align: center ;
}
.text-white {
color: white;
color: white ;
}
.stimol td,
.stimol th {
border: 1px solid black;
border: 1px solid black ;
}
.item {
height: 30px;
font-size: 11px;
height: 30px ;
font-size: 11px ;
}
h3 {
font-size: 14px;
font-size: 14px ;
}
h4 {
font-size: 12px;
font-size: 12px ;
}
p {
font-size: 11px;
font-size: 11px ;
}
</style>
</head>
@ -208,7 +208,7 @@
{% set rowIndex = 0 %}
{% for item in rows%}
{% if item.commodity %}
{% set taxAll = taxAll + item.tax %}
{% set taxAll = taxAll + item.tax %}
{% set rowIndex = rowIndex + 1 %}
<tr class="stimol">
<td class="center item">{{rowIndex}}</td>
@ -304,16 +304,16 @@
</li>
</ul>
{% endif %}
{# فیلد جدید وضعیت حساب مشتری #}
{ # فیلد جدید وضعیت حساب مشتری #}
{% if accountStatus is defined %}
<h4 class="">
وضعیت حساب مشتری با احتساب این فاکتور:
{{ accountStatus.value | number_format}}
{{ doc.money.shortName }}
{{ accountStatus.label }}
</h4>
{% endif %}
</div>
</h4>
@ -371,4 +371,3 @@
</div>
</body>
</body></div></body></html>

View file

@ -5,30 +5,174 @@
<script>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'
// Vite-friendly worker imports
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
// تنظیمات MonacoEnvironment برای web workers
if (typeof self !== 'undefined' && !self.MonacoEnvironment) {
// Register Twig language and completion once per app lifetime
let twigEnhancementsRegistered = false
function registerTwigLanguageAndCompletions() {
if (twigEnhancementsRegistered) return
try {
// Register Twig language
monaco.languages.register({ id: 'twig', extensions: ['.twig'], aliases: ['Twig', 'twig'] })
// Basic Twig tokenizer
monaco.languages.setMonarchTokensProvider('twig', {
tokenPostfix: '.twig',
defaultToken: '',
brackets: [
{ open: '{{', close: '}}', token: 'delimiter.curly' },
{ open: '{%', close: '%}', token: 'delimiter.curly' },
{ open: '{#', close: '#}', token: 'comment' }
],
keywords: [
'if', 'elseif', 'else', 'endif',
'for', 'endfor', 'in',
'set', 'include', 'extends', 'with', 'only',
'block', 'endblock', 'macro', 'import', 'from', 'filter', 'endfilter'
],
filters: [
'upper', 'lower', 'capitalize', 'title', 'trim', 'replace', 'default', 'length', 'escape', 'raw', 'number_format'
],
tokenizer: {
root: [
[/\{#/, 'comment', '@comment'],
[/\{\{/, 'delimiter.twig', '@variable'],
[/\{%/, 'delimiter.twig', '@block'],
[/[^\{]+/, ''],
[/./, '']
],
comment: [
[/[^#}]+/, 'comment.content'],
[/#\}/, 'comment', '@pop']
],
variable: [
[/\}\}/, 'delimiter.twig', '@pop'],
[/\|\s*[a-zA-Z_][\w]*/, 'keyword'],
[/\b([a-zA-Z_][\w]*)\b/, 'variable'],
[/[^}]+/, '']
],
block: [
[/%\}/, 'delimiter.twig', '@pop'],
[/\b(if|elseif|else|endif|for|endfor|in|set|include|extends|with|only|block|endblock|macro|import|from|filter|endfilter)\b/, 'keyword'],
[/\b([a-zA-Z_][\w]*)\b/, 'identifier'],
[/[^%]+/, '']
]
}
})
// Twig variables for invoice templates (autocomplete)
const twigVariables = [
{ label: 'company_name', detail: 'نام شرکت' },
{ label: 'invoice_number', detail: 'شماره فاکتور' },
{ label: 'invoice_date', detail: 'تاریخ فاکتور' },
{ label: 'customer_name', detail: 'نام مشتری' },
{ label: 'total_amount', detail: 'مبلغ کل' },
{ label: 'items_list', detail: 'لیست اقلام' }
]
const twigKeywords = [
{ label: 'if', detail: 'ساختار شرطی' },
{ label: 'elseif', detail: 'شرط جایگزین' },
{ label: 'else', detail: 'بخش جایگزین' },
{ label: 'endif', detail: 'پایان if' },
{ label: 'for', detail: 'حلقه' },
{ label: 'endfor', detail: 'پایان for' },
{ label: 'in', detail: 'اپراتور in' },
{ label: 'set', detail: 'تعریف متغیر' },
{ label: 'include', detail: 'درج قالب' },
{ label: 'extends', detail: 'ارث‌بری قالب' },
{ label: 'block', detail: 'بلوک قالب' },
{ label: 'endblock', detail: 'پایان بلوک' },
{ label: 'filter', detail: 'اعمال فیلتر' },
{ label: 'endfilter', detail: 'پایان فیلتر' }
]
const twigSnippets = [
{
label: 'if ... endif',
detail: 'الگوی if',
insertText: '{% if ${1:condition} %}\n ${2:...}\n{% endif %}',
},
{
label: 'for ... endfor',
detail: 'الگوی for',
insertText: '{% for ${1:item} in ${2:items} %}\n ${3:...}\n{% endfor %}'
}
]
const mapToMonacoItems = (items, kind) => items.map((it) => ({
label: it.label,
kind,
insertText: it.insertText || it.label,
insertTextRules: it.insertText ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined,
detail: it.detail
}))
// Utility: determine if cursor is inside a Twig pair on the current line
function isInsideTwigContext(model, position) {
const lineText = model.getLineContent(position.lineNumber)
const textUntilPos = lineText.substring(0, position.column - 1)
const openVar = textUntilPos.lastIndexOf('{{')
const closeVar = textUntilPos.lastIndexOf('}}')
const openBlock = textUntilPos.lastIndexOf('{%')
const closeBlock = textUntilPos.lastIndexOf('%}')
const inVar = openVar > -1 && openVar > closeVar
const inBlock = openBlock > -1 && openBlock > closeBlock
return inVar || inBlock
}
// Completion provider for Twig language
monaco.languages.registerCompletionItemProvider('twig', {
triggerCharacters: ['{', '%', ' ', '.', '_', '|'],
provideCompletionItems(model, position) {
const suggestions = [
...mapToMonacoItems(twigVariables, monaco.languages.CompletionItemKind.Variable),
...mapToMonacoItems(twigKeywords, monaco.languages.CompletionItemKind.Keyword),
...mapToMonacoItems(twigSnippets, monaco.languages.CompletionItemKind.Snippet)
]
return { suggestions }
}
})
// Also provide Twig suggestions while editing HTML (mixed content)
monaco.languages.registerCompletionItemProvider('html', {
triggerCharacters: ['{', '%', ' ', '.', '_', '|'],
provideCompletionItems(model, position) {
if (!isInsideTwigContext(model, position)) return { suggestions: [] }
const suggestions = [
...mapToMonacoItems(twigVariables, monaco.languages.CompletionItemKind.Variable),
...mapToMonacoItems(twigKeywords, monaco.languages.CompletionItemKind.Keyword),
...mapToMonacoItems(twigSnippets, monaco.languages.CompletionItemKind.Snippet)
]
return { suggestions }
}
})
twigEnhancementsRegistered = true
} catch (err) {
console.warn('Failed to register Twig language/completions:', err)
}
}
// تنظیمات MonacoEnvironment برای web workers (سازگار با Vite)
if (typeof self !== 'undefined') {
self.MonacoEnvironment = {
getWorkerUrl: function (moduleId, label) {
getWorker: function (moduleId, label) {
try {
// استفاده از web workers موجود در پوشه public
if (label === 'json') {
return '/monaco-editor/min/vs/language/json/json.worker.js'
}
if (label === 'css' || label === 'scss' || label === 'less') {
return '/monaco-editor/min/vs/language/css/css.worker.js'
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return '/monaco-editor/min/vs/language/html/html.worker.js'
}
if (label === 'typescript' || label === 'javascript') {
return '/monaco-editor/min/vs/language/typescript/ts.worker.js'
}
return '/monaco-editor/min/vs/editor/editor.worker.js'
if (label === 'json') return new JsonWorker()
if (label === 'css' || label === 'scss' || label === 'less') return new CssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor') return new HtmlWorker()
if (label === 'typescript' || label === 'javascript') return new TsWorker()
return new EditorWorker()
} catch (error) {
console.warn('Monaco Editor worker not found, falling back to main thread:', error)
// Fallback to main thread if workers fail
return null
console.warn('Monaco Editor worker failed to start, falling back to main thread:', error)
return undefined
}
}
}
@ -71,6 +215,9 @@ export default {
if (!editorContainer.value) return
try {
// Ensure Twig language and completions are available
registerTwigLanguageAndCompletions()
// تنظیمات پیشفرض
const defaultOptions = {
value: props.modelValue,
@ -173,6 +320,8 @@ export default {
})
watch(() => props.language, (newLanguage) => {
// Ensure Twig language is registered if needed when language changes
registerTwigLanguageAndCompletions()
if (editor) {
monaco.editor.setModelLanguage(editor.getModel(), newLanguage)
}

View file

@ -14,76 +14,23 @@
<v-btn v-bind="props" icon="mdi-content-save" color="primary" @click="saveTemplate" :loading="saving"></v-btn>
</template>
</v-tooltip>
<v-menu>
<v-tooltip text="پیش‌نمایش (HTML)" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon color="green">
<v-icon>mdi-eye</v-icon>
<v-tooltip activator="parent" text="پیش‌نمایش قالب" location="bottom" />
</v-btn>
</template>
<v-list>
<v-list-subheader color="primary">پیشنمایش قالب</v-list-subheader>
<v-list-item class="text-dark" title="پیش‌نمایش در مرورگر" @click="previewTemplate">
<template v-slot:prepend>
<v-icon color="blue-darken-4" icon="mdi-eye"></v-icon>
</template>
</v-list-item>
<v-list-item class="text-dark" title="پیش‌نمایش چاپ" @click="previewPrint">
<template v-slot:prepend>
<v-icon color="orange-darken-4" icon="mdi-printer"></v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon color="purple">
<v-icon>mdi-palette</v-icon>
<v-tooltip activator="parent" text="قالب‌های آماده" location="bottom" />
</v-btn>
</template>
<v-list>
<v-list-subheader color="primary">قالبهای آماده</v-list-subheader>
<v-list-item class="text-dark" title="قالب استاندارد" @click="loadTemplate('standard')">
<template v-slot:prepend>
<v-icon color="green-darken-4" icon="mdi-file-document"></v-icon>
</template>
</v-list-item>
<v-list-item class="text-dark" title="قالب لوکس" @click="loadTemplate('luxury')">
<template v-slot:prepend>
<v-icon color="amber-darken-4" icon="mdi-star"></v-icon>
</template>
</v-list-item>
<v-list-item class="text-dark" title="قالب تجاری" @click="loadTemplate('business')">
<template v-slot:prepend>
<v-icon color="blue-darken-4" icon="mdi-briefcase"></v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
<v-tooltip text="فرمت کردن کد" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-format-align-left" color="info" @click="formatCode" />
<v-btn v-bind="props" icon="mdi-eye" class="ml-2" @click="previewHtml" :disabled="!templateData.code"></v-btn>
</template>
</v-tooltip>
<v-tooltip text="تنظیمات قالب" location="bottom">
<v-tooltip text="دانلود پیش‌نمایش PDF" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-cog" color="primary" @click="showSettings = true" />
<v-btn v-bind="props" icon="mdi-file-pdf-box" color="secondary" class="ml-2" @click="previewPdf" :disabled="!templateData.code"></v-btn>
</template>
</v-tooltip>
</v-toolbar>
<v-tabs v-model="activeTab" color="primary" class="template-tabs">
<v-tab value="form">فرم قالب</v-tab>
<v-tab value="help">راهنما و آموزش</v-tab>
<v-tab value="settings">تنظیمات ویرایشگر</v-tab>
</v-tabs>
<v-container>
<v-tabs v-model="activeTab" color="primary" class="template-tabs">
<v-tab value="form">فرم قالب</v-tab>
<v-tab value="help">راهنما و آموزش</v-tab>
<v-tab value="settings">تنظیمات ویرایشگر</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="template-window">
<!-- فرم قالب -->
<v-window-item value="form">
@ -102,156 +49,324 @@
<v-row>
<v-col cols="12">
<v-label class="text-subtitle-2 mb-2 d-block">کد قالب</v-label>
<MonacoEditor
v-model="templateData.code"
:language="editorSettings.language"
:theme="editorSettings.theme"
height="500px"
:options="monacoOptions"
@change="onCodeChange"
ref="monacoEditor"
/>
<MonacoEditor v-model="templateData.code" :language="editorSettings.language"
:theme="editorSettings.theme" height="500px" :options="monacoOptions" @change="onCodeChange"
ref="monacoEditor" />
<div v-if="codeError" class="text-error text-caption mt-1">
{{ codeError }}
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<div class="form-actions">
<v-btn color="primary" size="large" @click="saveTemplate" :loading="saving">
<v-icon left>mdi-content-save</v-icon>
{{ isEditMode ? 'بروزرسانی قالب' : 'ذخیره قالب' }}
</v-btn>
<v-btn color="secondary" size="large" @click="goBack" class="ml-3">
<v-icon left>mdi-arrow-right</v-icon>
بازگشت
</v-btn>
</div>
</v-col>
</v-row>
</div>
</v-window-item>
<!-- راهنما و آموزش -->
<v-window-item value="help">
<v-container class="help-content">
<!-- مقدمه -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-information</v-icon>
راهنمای ایجاد قالب
راهنمای جامع طراحی قالب فاکتور سفارشی
</v-card-title>
<v-card-text>
<v-list class="help-steps">
<v-list-item class="help-step mb-4">
<template v-slot:prepend>
<v-avatar color="primary" size="30" class="step-number">
<span class="text-white font-weight-bold">1</span>
</v-avatar>
</template>
<v-list-item-title class="text-h6 mb-2">نام قالب</v-list-item-title>
<v-list-item-subtitle>
نام مناسبی برای قالب خود انتخاب کنید که نشاندهنده نوع و کاربرد آن باشد.
</v-list-item-subtitle>
</v-list-item>
<v-list-item class="help-step mb-4">
<template v-slot:prepend>
<v-avatar color="primary" size="30" class="step-number">
<span class="text-white font-weight-bold">2</span>
</v-avatar>
</template>
<v-list-item-title class="text-h6 mb-2">وضعیت عمومی</v-list-item-title>
<v-list-item-subtitle>
اگر قالب را عمومی انتخاب کنید، سایر کاربران نیز میتوانند از آن استفاده کنند.
</v-list-item-subtitle>
</v-list-item>
<v-list-item class="help-step mb-4">
<template v-slot:prepend>
<v-avatar color="primary" size="30" class="step-number">
<span class="text-white font-weight-bold">3</span>
</v-avatar>
</template>
<v-list-item-title class="text-h6 mb-2">کد قالب</v-list-item-title>
<v-list-item-subtitle>
کد HTML و CSS قالب خود را در این قسمت وارد کنید. میتوانید از متغیرهای زیر استفاده کنید:
</v-list-item-subtitle>
<v-card class="code-variables mt-3" variant="outlined">
<v-card-text class="font-family-monospace">
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ company_name }}'"></v-chip> - نام شرکت<br>
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ invoice_number }}'"></v-chip> - شماره
فاکتور<br>
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ invoice_date }}'"></v-chip> - تاریخ
فاکتور<br>
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ customer_name }}'"></v-chip> - نام
مشتری<br>
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ total_amount }}'"></v-chip> - مبلغ کل<br>
<v-chip color="error" size="small" class="mr-2 mb-1" v-text="'{{ items_list }}'"></v-chip> - لیست اقلام
</v-card-text>
</v-card>
</v-list-item>
</v-list>
<p>
در این بخش میتوانید با استفاده از زبان قالببندی Twig و HTML/CSS، قالب فاکتور سفارشی خود را طراحی کنید.
برای جلوگیری از مشکلات امنیتی، رندر کدها در <b>Sandbox</b> انجام میشود؛ بنابراین تنها بخشی از امکانات Twig مجاز است.
</p>
<ul class="mt-3">
<li>تگهای مجاز: <b>if</b> و <b>for</b></li>
<li>فیلترهای مجاز: <b>escape</b>، <b>upper</b>، <b>lower</b>، <b>number_format</b>، <b>join</b></li>
<li>تابع/متد مستقیم غیرفعال است؛ فقط از دادههای فراهمشده در کانتکست استفاده کنید.</li>
</ul>
</v-card-text>
</v-card>
<!-- متغیرهای در دسترس -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-database</v-icon>
متغیرهای در دسترس در قالب
</v-card-title>
<v-card-text>
<p>در زمان رندر، دادههای زیر در اختیار قالب قرار میگیرد:</p>
<ul class="mt-3">
<li>
<b>business</b>: اطلاعات کسبوکار
<ul>
<li><code>business.name</code>, <code>business.tel</code>, <code>business.mobile</code>, <code>business.address</code></li>
<li><code>business.shenasemeli</code>, <code>business.codeeghtesadi</code></li>
</ul>
</li>
<li>
<b>doc</b>: اطلاعات سند
<ul>
<li><code>doc.code</code>, <code>doc.date</code></li>
<li>در فروش: <code>doc.taxPercent</code>, <code>doc.discountPercent</code>, <code>doc.discountType</code></li>
</ul>
</li>
<li>
<b>person</b>: اطلاعات مشتری/طرف حساب
<ul>
<li><code>person.name</code>, <code>person.mobile</code>, <code>person.tel</code>, <code>person.address</code></li>
</ul>
</li>
<li>
<b>rows</b>: آرایه اقلام فاکتور (هر ردیف یک شیء)
<ul>
<li><code>row.commodity.name</code>, <code>row.commodity.code</code> (ممکن است <code>commodity</code> تهی باشد)</li>
<li><code>row.commodityCount</code> (و برای سازگاری قدیمی: <code>row.commdityCount</code>)</li>
<li><code>row.des</code>, <code>row.bs</code>, <code>row.bd</code> (در خرید/برگشت از خرید)، <code>row.tax</code>, <code>row.discount</code></li>
<li>در فروش/برگشت از فروش: <code>row.showPercentDiscount</code>, <code>row.discountPercent</code></li>
</ul>
</li>
<li>
<b>discount</b> و <b>transfer</b>: جمع تخفیف و هزینه ارسال/انتقال در سطح فاکتور (در صورت وجود)
</li>
<li>
<b>note</b>: یادداشت پایین فاکتور (از تنظیمات چاپ)
</li>
<li>
<b>printOptions</b>: تنظیمات چاپ جاری (مانند <code>paper</code>، <code>bidInfo</code>، ...)
</li>
<li>
در فروش: <b>accountStatus</b> با کلیدهای <code>label</code> و <code>value</code>
</li>
</ul>
</v-card-text>
</v-card>
<!-- نمونه: حلقه و شرط -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-code-tags</v-icon>
نمونه کد قالب
مثالهای کاربردی Twig (حلقهها و شرطها)
</v-card-title>
<v-card-text>
<v-card class="code-example" variant="outlined">
<div class="code-example" variant="outlined">
<v-card-text class="font-family-monospace">
<pre class="text-body-2"><code v-text="codeExample"></code></pre>
<pre class="text-body-2" v-pre><code>{% if person %}
&lt;p&gt;مشتری: {{ person.name | escape }}&lt;/p&gt;
{% endif %}
&lt;table width=&quot;100%&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; border=&quot;1&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;کالا&lt;/th&gt;
&lt;th&gt;تعداد&lt;/th&gt;
&lt;th&gt;فی توضیح&lt;/th&gt;
&lt;th&gt;مالیات&lt;/th&gt;
&lt;th&gt;تخفیف&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
{% for item in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ item.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ item.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ item.des | escape }}&lt;/td&gt;
&lt;td&gt;{{ item.tax | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;
{% if item.showPercentDiscount %}
{{ item.discountPercent }}%
{% else %}
{{ item.discount | number_format(0, '.', ',') }}
{% endif %}
&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
</code></pre>
</v-card-text>
</v-card>
</div>
</v-card-text>
</v-card>
<v-card class="help-section" variant="outlined">
<!-- نمونه کامل فروش -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-lightbulb</v-icon>
نکات مهم
<v-icon color="primary" class="mr-3">mdi-file-document</v-icon>
نمونه کامل برای فاکتور فروش
</v-card-title>
<v-card-text>
<v-list class="tips-list">
<v-list-item class="tips-item">
<template v-slot:prepend>
<v-icon color="success" class="mr-3">mdi-check</v-icon>
</template>
<v-list-item-title>از CSS برای زیبایی قالب استفاده کنید</v-list-item-title>
</v-list-item>
<pre class="text-body-2" v-pre><code>&lt;div class=&quot;invoice-template&quot;&gt;
&lt;h2 style=&quot;text-align:center&quot;&gt;{{ business.name }}&lt;/h2&gt;
&lt;div&gt;شماره فاکتور: {{ doc.code }} | تاریخ: {{ doc.date }}&lt;/div&gt;
{% if person %}&lt;div&gt;مشتری: {{ person.name }} | موبایل: {{ person.mobile }}&lt;/div&gt;{% endif %}
<v-list-item class="tips-item">
<template v-slot:prepend>
<v-icon color="success" class="mr-3">mdi-check</v-icon>
</template>
<v-list-item-title>مطمئن شوید که قالب در چاپ به خوبی نمایش داده میشود</v-list-item-title>
</v-list-item>
&lt;table width=&quot;100%&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; border=&quot;1&quot; style=&quot;margin-top:10px&quot;&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;کالا&lt;/th&gt;&lt;th&gt;تعداد&lt;/th&gt;&lt;th&gt;شرح&lt;/th&gt;&lt;th&gt;مالیات&lt;/th&gt;&lt;th&gt;تخفیف&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
{% for r in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ r.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ r.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ r.des }}&lt;/td&gt;
&lt;td&gt;{{ r.tax | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;{% if r.showPercentDiscount %}{{ r.discountPercent }}%{% else %}{{ r.discount | number_format(0, '.', ',') }}{% endif %}&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
<v-list-item class="tips-item">
<template v-slot:prepend>
<v-icon color="success" class="mr-3">mdi-check</v-icon>
</template>
<v-list-item-title>از فونتهای فارسی استفاده کنید</v-list-item-title>
</v-list-item>
&lt;div style=&quot;margin-top:10px;text-align:right&quot;&gt;
{% if discount %}&lt;div&gt;جمع تخفیف: {{ discount | number_format(0, '.', ',') }}&lt;/div&gt;{% endif %}
{% if transfer %}&lt;div&gt;هزینه ارسال/انتقال: {{ transfer | number_format(0, '.', ',') }}&lt;/div&gt;{% endif %}
&lt;/div&gt;
<v-list-item class="tips-item">
<template v-slot:prepend>
<v-icon color="success" class="mr-3">mdi-check</v-icon>
</template>
<v-list-item-title>رنگبندی مناسب برای خوانایی انتخاب کنید</v-list-item-title>
</v-list-item>
{% if note %}
&lt;div style=&quot;margin-top:12px;border-top:1px dashed #ccc;padding-top:8px&quot;&gt;{{ note | escape }}&lt;/div&gt;
{% endif %}
&lt;/div&gt;
</code></pre>
</v-card-text>
</v-card>
<!-- نمونه کامل خرید / برگشت از خرید -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-file-document-outline</v-icon>
نمونه کامل برای خرید / برگشت از خرید
</v-card-title>
<v-card-text>
<p>در خرید/برگشت از خرید، مقادیر ردیفها شامل <code>bs</code> و <code>bd</code> نیز هستند.</p>
<pre class="text-body-2" v-pre><code>{% for r in rows %}
&lt;div&gt;{{ loop.index }}. {{ r.commodity.name ?? '-' }} | تعداد: {{ r.commodityCount }} | شرح: {{ r.des }} | بدهکار: {{ r.bd }} | بستانکار: {{ r.bs }}&lt;/div&gt;
{% endfor %}
</code></pre>
</v-card-text>
</v-card>
<!-- نکات امنیتی و محدودیتها -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-shield-lock</v-icon>
نکات امنیتی و محدودیتها
</v-card-title>
<v-card-text>
<ul>
<li>از اجرای جاوااسکریپت، توابع سیستم یا درخواستهای خارجی در قالب پرهیز شده و امکانپذیر نیست.</li>
<li>برای جلوگیری از خطاهای سازگاری، از <b>commodityCount</b> استفاده کنید (هرچند برای سازگاری <b>commdityCount</b> نیز پشتیبانی میشود).</li>
<li>به دلیل Sandbox، دسترسی به متد/پراپرتیهای آبجکتها محدود است؛ از دادههای آرایهای فراهمشده استفاده کنید.</li>
</ul>
</v-card-text>
</v-card>
<!-- عیبیابی متداول -->
<v-card class="help-section" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-lifebuoy</v-icon>
عیبیابی متداول
</v-card-title>
<v-card-text>
<ul>
<li>خطای «Key X does not exist»: نام کلید را با فهرست بالا تطبیق دهید؛ برای تعداد از <code>commodityCount</code> استفاده کنید.</li>
<li>عدم نمایش اطلاعات مشتری: ابتدا بررسی کنید <code>person</code> تهی نباشد: <code>{% if person %} ... {% endif %}</code></li>
<li>بههمریختگی چاپ: از CSS ساده و سازگار با چاپ استفاده کنید؛ عرض جدولها و اندازه فونتها را کنترل کنید.</li>
</ul>
</v-card-text>
</v-card>
<!-- قالبهای پیشفرض: فروش، خرید، برگشتها -->
<v-card class="help-section mb-6" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon color="primary" class="mr-3">mdi-format-list-text</v-icon>
قالبهای پیشفرض (نمونههای آماده)
</v-card-title>
<v-card-text>
<p>در ادامه نمونههایی از قالبهای پیشفرض سیستم برای الهام گرفتن آورده شده است.</p>
<h4 class="mt-4">فروش (نمونه ساده)</h4>
<pre class="text-body-2" v-pre><code>&lt;div class=&quot;invoice-template&quot;&gt;
&lt;h3 style=&quot;text-align:center&quot;&gt;{{ business.name }}&lt;/h3&gt;
&lt;div&gt;کد: {{ doc.code }} | تاریخ: {{ doc.date }}&lt;/div&gt;
{% if person %}&lt;div&gt;مشتری: {{ person.name }}&lt;/div&gt;{% endif %}
&lt;table width=&quot;100%&quot; border=&quot;1&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; style=&quot;margin-top:8px&quot;&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;کالا&lt;/th&gt;&lt;th&gt;تعداد&lt;/th&gt;&lt;th&gt;شرح&lt;/th&gt;&lt;th&gt;مالیات&lt;/th&gt;&lt;th&gt;تخفیف&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
{% for r in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ r.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ r.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ r.des }}&lt;/td&gt;
&lt;td&gt;{{ r.tax | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;{% if r.showPercentDiscount %}{{ r.discountPercent }}%{% else %}{{ r.discount | number_format(0, '.', ',') }}{% endif %}&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
{% if note %}&lt;div style=&quot;margin-top:8px&quot;&gt;{{ note | escape }}&lt;/div&gt;{% endif %}
&lt;/div&gt;</code></pre>
<h4 class="mt-6">خرید (نمونه ساده)</h4>
<pre class="text-body-2" v-pre><code>&lt;div class=&quot;invoice-template&quot;&gt;
&lt;h3 style=&quot;text-align:center&quot;&gt;{{ business.name }}&lt;/h3&gt;
&lt;div&gt;کد: {{ doc.code }} | تاریخ: {{ doc.date }}&lt;/div&gt;
{% if person %}&lt;div&gt;فروشنده: {{ person.name }}&lt;/div&gt;{% endif %}
&lt;table width=&quot;100%&quot; border=&quot;1&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; style=&quot;margin-top:8px&quot;&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;کالا&lt;/th&gt;&lt;th&gt;تعداد&lt;/th&gt;&lt;th&gt;شرح&lt;/th&gt;&lt;th&gt;بدهکار&lt;/th&gt;&lt;th&gt;بستانکار&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
{% for r in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ r.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ r.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ r.des }}&lt;/td&gt;
&lt;td&gt;{{ r.bd | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;{{ r.bs | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;</code></pre>
<h4 class="mt-6">برگشت از خرید (نمونه ساده)</h4>
<pre class="text-body-2" v-pre><code>&lt;div class=&quot;invoice-template&quot;&gt;
&lt;h3 style=&quot;text-align:center&quot;&gt;{{ business.name }}&lt;/h3&gt;
&lt;div&gt;کد: {{ doc.code }} | تاریخ: {{ doc.date }}&lt;/div&gt;
&lt;table width=&quot;100%&quot; border=&quot;1&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; style=&quot;margin-top:8px&quot;&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;کالا&lt;/th&gt;&lt;th&gt;تعداد&lt;/th&gt;&lt;th&gt;شرح&lt;/th&gt;&lt;th&gt;بدهکار&lt;/th&gt;&lt;th&gt;بستانکار&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
{% for r in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ r.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ r.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ r.des }}&lt;/td&gt;
&lt;td&gt;{{ r.bd | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;{{ r.bs | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;</code></pre>
<h4 class="mt-6">برگشت از فروش (نمونه ساده)</h4>
<pre class="text-body-2" v-pre><code>&lt;div class=&quot;invoice-template&quot;&gt;
&lt;h3 style=&quot;text-align:center&quot;&gt;{{ business.name }}&lt;/h3&gt;
&lt;div&gt;کد: {{ doc.code }} | تاریخ: {{ doc.date }}&lt;/div&gt;
&lt;table width=&quot;100%&quot; border=&quot;1&quot; cellspacing=&quot;0&quot; cellpadding=&quot;6&quot; style=&quot;margin-top:8px&quot;&gt;
&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;کالا&lt;/th&gt;&lt;th&gt;تعداد&lt;/th&gt;&lt;th&gt;شرح&lt;/th&gt;&lt;th&gt;مالیات&lt;/th&gt;&lt;th&gt;تخفیف&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
{% for r in rows %}
&lt;tr&gt;
&lt;td&gt;{{ loop.index }}&lt;/td&gt;
&lt;td&gt;{{ r.commodity.name ?? '-' }}&lt;/td&gt;
&lt;td&gt;{{ r.commodityCount }}&lt;/td&gt;
&lt;td&gt;{{ r.des }}&lt;/td&gt;
&lt;td&gt;{{ r.tax | number_format(0, '.', ',') }}&lt;/td&gt;
&lt;td&gt;{% if r.showPercentDiscount %}{{ r.discountPercent }}%{% else %}{{ r.discount | number_format(0, '.', ',') }}{% endif %}&lt;/td&gt;
&lt;/tr&gt;
{% endfor %}
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;</code></pre>
<v-list-item class="tips-item">
<template v-slot:prepend>
<v-icon color="success" class="mr-3">mdi-check</v-icon>
</template>
<v-list-item-title>قبل از ذخیره، قالب را تست کنید</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-container>
@ -272,23 +387,25 @@
color="primary" @update:model-value="applySettings" />
</v-col>
<v-col cols="12" md="6">
<v-switch v-model="editorSettings.wordWrap" label="شکستن خودکار خط" color="primary" @update:model-value="applySettings" />
<v-switch v-model="editorSettings.wordWrap" label="شکستن خودکار خط" color="primary"
@update:model-value="applySettings" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-switch v-model="editorSettings.minimap" label="نمایش مینی‌مپ" color="primary"
@update:model-value="applySettings" />
</v-col>
<v-col cols="12" md="6">
<v-switch v-model="editorSettings.autoComplete" label="تکمیل خودکار کد" color="primary"
@update:model-value="applySettings" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-switch v-model="editorSettings.minimap" label="نمایش مینی‌مپ" color="primary" @update:model-value="applySettings" />
</v-col>
<v-col cols="12" md="6">
<v-switch v-model="editorSettings.autoComplete" label="تکمیل خودکار کد" color="primary" @update:model-value="applySettings" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select v-model="editorSettings.theme" label="تم ویرایشگر" :items="themeOptions"
variant="outlined" @update:model-value="applySettings" />
<v-select v-model="editorSettings.theme" label="تم ویرایشگر" :items="themeOptions" variant="outlined"
@update:model-value="applySettings" />
</v-col>
<v-col cols="12" md="6">
<v-select v-model="editorSettings.language" label="زبان کد" :items="languageOptions"
@ -335,6 +452,7 @@
<script>
import MonacoEditor from '@/components/MonacoEditor.vue'
import axios from 'axios'
export default {
name: 'CustomInvoiceTemplateForm',
@ -386,8 +504,9 @@ export default {
{ title: 'HTML', value: 'html' },
{ title: 'CSS', value: 'css' },
{ title: 'JavaScript', value: 'javascript' },
{ title: 'TypeScript', value: 'typescript' }
]
{ title: 'TypeScript', value: 'typescript' },
{ title: 'Twig', value: 'twig' }
]
}
},
computed: {
@ -427,21 +546,54 @@ export default {
}
},
methods: {
saveTemplate() {
// Validation
async loadExistingTemplate() {
try {
const { data } = await axios.get(`/api/plugins/custominvoice/template/${this.templateId}`)
const t = data?.data
if (t) {
this.templateData.name = t.name || ''
this.templateData.isPublic = !!t.isPublic
this.templateData.code = t.code || ''
}
} catch (e) {
const msg = e?.response?.data?.message || 'خطا در بارگذاری قالب'
this.$toast?.error(msg)
}
},
async saveTemplate() {
if (!this.templateData.name || !this.templateData.code) {
// Show error message
this.$toast?.error('نام و کد قالب الزامی است.');
return;
}
this.saving = true;
try {
const payload = {
name: this.templateData.name,
isPublic: this.templateData.isPublic,
code: this.templateData.code,
};
// Simulate API call
setTimeout(() => {
this.saving = false;
// Navigate back to templates list
let response;
if (this.isEditMode && this.templateId) {
response = await axios.post(`/api/plugins/custominvoice/template/${this.templateId}`, payload);
} else {
response = await axios.post('/api/plugins/custominvoice/template', payload);
}
const data = response.data?.data || {};
if (!this.isEditMode) {
this.templateId = data.id;
this.isEditMode = true;
}
this.$toast?.success('قالب با موفقیت ذخیره شد.');
this.$router.push('/acc/plugins/custominvoice/templates');
}, 1000);
} catch (e) {
const msg = e?.response?.data?.message || 'خطا در ذخیره قالب';
this.$toast?.error(msg);
} finally {
this.saving = false;
}
},
goBack() {
this.$router.push('/acc/plugins/custominvoice/templates');
@ -495,7 +647,6 @@ export default {
onCodeChange(value) {
this.codeError = '';
try {
// Attempt to parse the HTML to check for errors
new DOMParser().parseFromString(value, 'text/html');
} catch (e) {
this.codeError = 'کد HTML معتبر نیست. لطفاً خطاهای آن را برطرف کنید.';
@ -537,13 +688,13 @@ export default {
applySettings() {
// جلوگیری از اعمال مکرر تنظیمات
if (this.applyingSettings) return;
this.applyingSettings = true;
// اعمال تنظیمات روی Monaco Editor
if (this.$refs.monacoEditor && this.$refs.monacoEditor.editor) {
const editor = this.$refs.monacoEditor.editor;
// اعمال تنظیمات جدید
editor.updateOptions({
fontSize: this.editorSettings.fontSize,
@ -557,17 +708,17 @@ export default {
acceptSuggestionOnEnter: this.editorSettings.autoComplete ? 'on' : 'off',
tabCompletion: this.editorSettings.autoComplete ? 'on' : 'off'
});
// تغییر تم
if (window.monaco) {
window.monaco.editor.setTheme(this.editorSettings.theme);
}
}
// ذخیره تنظیمات در localStorage
localStorage.setItem('monacoEditorSettings', JSON.stringify(this.editorSettings));
localStorage.setItem('appearanceSettings', JSON.stringify(this.appearanceSettings));
setTimeout(() => {
this.applyingSettings = false;
// نمایش پیام موفقیت فقط در صورت نیاز
@ -590,11 +741,11 @@ export default {
primaryColor: '#667eea',
secondaryColor: '#42b883'
};
// اعمال تنظیمات بازنشانی شده روی Monaco Editor
if (this.$refs.monacoEditor && this.$refs.monacoEditor.editor) {
const editor = this.$refs.monacoEditor.editor;
editor.updateOptions({
fontSize: this.editorSettings.fontSize,
wordWrap: this.editorSettings.wordWrap ? 'on' : 'off',
@ -607,17 +758,17 @@ export default {
acceptSuggestionOnEnter: this.editorSettings.autoComplete ? 'on' : 'off',
tabCompletion: this.editorSettings.autoComplete ? 'on' : 'off'
});
// تغییر تم
if (window.monaco) {
window.monaco.editor.setTheme(this.editorSettings.theme);
}
}
// پاک کردن تنظیمات از localStorage
localStorage.removeItem('monacoEditorSettings');
localStorage.removeItem('appearanceSettings');
this.$toast?.success('تنظیمات با موفقیت بازنشانی شد.');
},
loadSavedSettings() {
@ -627,7 +778,7 @@ export default {
if (savedEditorSettings) {
this.editorSettings = { ...this.editorSettings, ...JSON.parse(savedEditorSettings) };
}
// بارگذاری تنظیمات ظاهری
const savedAppearanceSettings = localStorage.getItem('appearanceSettings');
if (savedAppearanceSettings) {
@ -636,14 +787,73 @@ export default {
} catch (error) {
console.error('خطا در بارگذاری تنظیمات:', error);
}
},
async previewHtml() {
try {
const { data } = await axios.post('/api/plugins/custominvoice/template/preview', {
code: this.templateData.code,
paper: 'A4-L'
})
if (data?.status === 'ok') {
const w = window.open('', '_blank')
w.document.write(data.html)
w.document.close()
if (data.warning && this.$toast) this.$toast.info(data.warning)
} else {
const msg = data?.message || 'خطا در پیش‌نمایش'
this.$toast?.error(msg)
}
} catch (e) {
const msg = e?.response?.data?.message || 'خطا در پیش‌نمایش'
this.$toast?.error(msg)
}
},
async previewPdf() {
try {
// Request preview generation to get printId
const { data } = await axios.post('/api/plugins/custominvoice/template/preview', {
code: this.templateData.code,
paper: 'A4-L'
})
if (data?.status !== 'ok' || !data.printId) {
const msg = data?.message || 'خطا در ایجاد پیش‌نمایش PDF'
this.$toast?.error(msg)
return
}
// Fetch the PDF binary like sell list implementation
const pdfResponse = await axios({
method: 'get',
url: `/front/print/${data.printId}`,
responseType: 'arraybuffer'
})
const blob = new Blob([pdfResponse.data], { type: 'application/pdf' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const baseName = this.templateData.name ? this.templateData.name : 'پیش‌نمایش قالب'
link.setAttribute('download', `${baseName}.pdf`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
if (data.warning && this.$toast) this.$toast.info(data.warning)
} catch (e) {
const msg = e?.response?.data?.message || 'خطا در دریافت فایل PDF'
this.$toast?.error(msg)
}
}
},
mounted() {
// Check if we're in edit mode by checking the route parameter
this.templateId = this.$route.params.id;
this.isEditMode = !!this.templateId;
// بارگذاری تنظیمات ذخیره شده از localStorage
if (this.isEditMode) {
this.loadExistingTemplate();
}
this.loadSavedSettings();
if (this.$store) {

View file

@ -1,224 +1,334 @@
<template>
<div class="templates-container">
<div class="templates-header">
<h1>مدیریت قالبهای فاکتور</h1>
<p class="templates-description">
طراحی و مدیریت قالبهای اختصاصی فاکتورهای فروش
</p>
</div>
<div>
<v-toolbar color="toolbar" title="قالب‌های فاکتور سفارشی">
<template v-slot:prepend>
<v-tooltip text="بازگشت" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="$router.back()" class="d-none d-sm-flex" variant="text" icon="mdi-arrow-right" />
</template>
</v-tooltip>
</template>
<v-spacer></v-spacer>
<div class="templates-content">
<div class="templates-placeholder">
<div class="placeholder-icon">
<i class="fas fa-palette"></i>
</div>
<h3>بخش قالبهای فاکتور</h3>
<p>این بخش در حال توسعه است و به زودی در دسترس خواهد بود.</p>
<div class="placeholder-features">
<router-link to="/acc/plugins/custominvoice/template/mod/" class="feature-item-link">
<div class="feature-item">
<i class="fas fa-plus"></i>
<span>ایجاد قالب جدید</span>
</div>
</router-link>
<div class="feature-item">
<i class="fas fa-edit"></i>
<span>ویرایش قالبها</span>
<v-tooltip text="قالب جدید" location="bottom">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-plus" color="primary" to="/acc/plugins/custominvoice/template/mod/" :disabled="activeTab !== 'business'"></v-btn>
</template>
</v-tooltip>
</v-toolbar>
<v-container class="pa-0 ma-0">
<v-card :loading="loading" :disabled="loading">
<v-card-text class="pa-0">
<v-tabs v-model="activeTab" color="primary" class="px-4">
<v-tab value="business">قالبهای کسبوکار</v-tab>
<v-tab value="public">قالبهای عمومی</v-tab>
</v-tabs>
<div class="pa-4">
<v-data-table-server
v-model:items-per-page="serverOptions.rowsPerPage"
v-model:page="serverOptions.page"
v-model:sort-by="serverOptions.sortBy"
:headers="computedHeaders"
:items="items"
:items-length="totalItems"
:loading="loading"
class="elevation-1 rounded-lg custom-table"
:items-per-page-options="[5, 10, 20, 50]"
item-value="id"
items-per-page-text="تعداد سطر"
no-data-text="اطلاعاتی برای نمایش وجود ندارد"
:header-props="{ class: 'custom-header' }"
density="comfortable"
>
<template #top>
<v-toolbar density="comfortable" color="transparent" class="px-0">
<v-row class="w-100 ma-0" align="center" no-gutters>
<v-col cols="12" md="6">
<v-text-field
v-model="searchValue"
color="info"
hide-details="auto"
rounded="0"
variant="outlined"
density="compact"
placeholder="جستجو در نام یا کد قالب"
type="text"
clearable
prepend-inner-icon="mdi-magnify"
/>
</v-col>
<v-col cols="12" md="6" class="d-flex align-center justify-end">
<v-chip size="small" color="info" variant="flat">تعداد نتایج: {{ totalItems }}</v-chip>
<v-btn variant="text" color="primary" icon="mdi-refresh" @click="refresh" :disabled="loading" />
<v-btn variant="text" color="secondary" icon="mdi-close-circle" @click="clearSearch" :disabled="!searchValue" />
</v-col>
</v-row>
</v-toolbar>
</template>
<template #item.operation="{ item }">
<v-menu v-if="item && showRowActions(item)">
<template #activator="{ props }">
<v-btn variant="text" size="small" color="error" icon="mdi-menu" v-bind="props" />
</template>
<v-list>
<v-list-item v-if="activeTab === 'business'" :title="'ویرایش'" :to="'/acc/plugins/custominvoice/template/mod/' + item.id">
<template #prepend>
<v-icon icon="mdi-file-edit"></v-icon>
</template>
</v-list-item>
<v-list-item v-if="activeTab === 'business'" :title="'حذف'" @click="deleteItem(item.id)">
<template #prepend>
<v-icon color="deep-orange-accent-4" icon="mdi-trash-can"></v-icon>
</template>
</v-list-item>
<v-list-item v-if="activeTab === 'public'" :title="'کپی و ویرایش'" @click="copyAndEdit(item.id)">
<template #prepend>
<v-icon color="primary" icon="mdi-content-copy"></v-icon>
</template>
</v-list-item>
</v-list>
</v-menu>
</template>
<template #item.isPublic="{ item }">
<v-icon v-if="item && item.isPublic" color="success">mdi-check</v-icon>
<v-icon v-else-if="item" color="error">mdi-close</v-icon>
</template>
<template #item.submitter="{ item }">
<span v-if="item && item.submitter">
{{ item.submitter.fullName || item.submitter.email }}
</span>
</template>
<template #item.name="{ item }">
<div class="name-cell">
<v-icon v-if="item && item.isPublic" size="18" color="success">mdi-earth</v-icon>
<span v-if="item">{{ item.name }}</span>
</div>
</template>
<template #item.copyCount="{ item }">
<v-chip v-if="activeTab === 'public'" size="small" color="indigo" variant="flat">{{ item.copyCount || 0 }}</v-chip>
</template>
<template #item.popular="{ item }">
<v-icon v-if="activeTab === 'public' && (item.copyCount || 0) > 10" color="amber">mdi-star</v-icon>
</template>
<template #loading>
<div class="pa-4">
<v-skeleton-loader type="table" />
</div>
</template>
<template #no-data>
<div class="text-center pa-8">
<v-icon size="48" color="grey">mdi-file-search</v-icon>
<div class="mt-2 mb-4">اطلاعاتی برای نمایش وجود ندارد</div>
<v-btn color="primary" variant="tonal" @click="clearSearch">حذف جستجو</v-btn>
</div>
</template>
</v-data-table-server>
</div>
<div class="feature-item">
<i class="fas fa-eye"></i>
<span>پیشنمایش قالبها</span>
</div>
<div class="feature-item">
<i class="fas fa-download"></i>
<span>صدور قالبها</span>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
<v-dialog v-model="confirmDeleteDialog" max-width="420">
<v-card>
<v-card-title class="text-h6">حذف قالب</v-card-title>
<v-card-text>آیا از حذف این قالب مطمئن هستید؟</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmDeleteDialog = false">انصراف</v-btn>
<v-btn color="error" @click="confirmDelete">بله، حذف شود</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showMessageDialog" max-width="360">
<v-card>
<v-card-title class="text-h6">{{ messageTitle }}</v-card-title>
<v-card-text>{{ messageText }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" @click="showMessageDialog = false">باشه</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script>
export default {
name: 'CustomInvoiceTemplates',
data() {
return {
// Data will be added here
<script setup>
import { ref, watch, onMounted, computed } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
const router = useRouter()
const loading = ref(true)
const searchValue = ref('')
const items = ref([])
const totalItems = ref(0)
const activeTab = ref('business') // business | public
const serverOptions = ref({
page: 1,
rowsPerPage: 10,
sortBy: [],
})
const baseHeaders = [
{ title: 'عملیات', key: 'operation', sortable: false, align: 'center' },
{ title: 'نام قالب', key: 'name', sortable: true, align: 'start' },
{ title: 'عمومی', key: 'isPublic', sortable: true, align: 'center' },
{ title: 'سازنده', key: 'submitter', sortable: false, align: 'start' },
]
const publicExtraHeaders = [
{ title: 'تعداد کپی', key: 'copyCount', sortable: true, align: 'center' },
{ title: 'محبوب', key: 'popular', sortable: false, align: 'center' },
]
const computedHeaders = computed(() => {
return activeTab.value === 'public' ? [...baseHeaders, ...publicExtraHeaders] : baseHeaders
})
let debounceTimer
const confirmDeleteDialog = ref(false)
const deleteTargetId = ref(null)
const showMessageDialog = ref(false)
const messageTitle = ref('')
const messageText = ref('')
const openMessage = (title, text) => {
messageTitle.value = title
messageText.value = text
showMessageDialog.value = true
}
const fetchData = async () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(async () => {
loading.value = true
try {
const sortBy = serverOptions.value.sortBy.map((sort) => ({
key: sort.key,
order: sort.order === 'asc' ? 'ASC' : 'DESC',
}))
const response = await axios.post('/api/plugins/custominvoice/template/list', {
page: serverOptions.value.page,
itemsPerPage: serverOptions.value.rowsPerPage,
search: searchValue.value,
sortBy: sortBy.length > 0 ? sortBy : null,
scope: activeTab.value,
})
items.value = response.data.items || []
totalItems.value = response.data.total || 0
} catch (error) {
console.error('Error fetching data:', error)
items.value = []
totalItems.value = 0
} finally {
loading.value = false
}
},
methods: {
// Methods will be added here
},
mounted() {
if (this.$store) {
this.$store.commit('setPageTitle', 'مدیریت قالب‌های فاکتور')
}, 400)
}
const refresh = () => { fetchData() }
const clearSearch = () => { searchValue.value = '' }
watch(searchValue, fetchData)
watch(serverOptions, fetchData, { deep: true })
watch(activeTab, () => {
serverOptions.value.page = 1
// default sort for public tab: by copyCount desc
if (activeTab.value === 'public') {
serverOptions.value.sortBy = [{ key: 'copyCount', order: 'desc' }]
} else {
serverOptions.value.sortBy = []
}
fetchData()
})
onMounted(() => {
fetchData()
})
const showRowActions = (item) => {
return (activeTab.value === 'business' && item && item.ownedByMe) || activeTab.value === 'public'
}
const copyAndEdit = async (id) => {
try {
const { data } = await axios.post(`/api/plugins/custominvoice/template/${id}/copy`)
if (data && data.data && data.data.id) {
router.push(`/acc/plugins/custominvoice/template/mod/${data.data.id}`)
} else {
throw new Error('invalid response')
}
} catch (e) {
openMessage('خطا', 'کپی قالب با خطا مواجه شد')
}
}
const deleteItem = (id) => {
deleteTargetId.value = id
confirmDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deleteTargetId.value) {
confirmDeleteDialog.value = false
return
}
try {
await axios.delete(`/api/plugins/custominvoice/template/${deleteTargetId.value}`)
confirmDeleteDialog.value = false
openMessage('حذف شد', 'قالب با موفقیت حذف شد')
fetchData()
} catch (e) {
confirmDeleteDialog.value = false
openMessage('خطا', 'حذف قالب با خطا مواجه شد')
} finally {
deleteTargetId.value = null
}
}
</script>
<style scoped>
.templates-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
.custom-header {
background: #f7f7f7;
}
.templates-header {
text-align: center;
margin-bottom: 40px;
padding: 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
color: white;
.custom-table .v-table__wrapper {
max-height: 520px;
overflow-y: auto;
}
.templates-header h1 {
margin: 0 0 15px 0;
font-size: 2.5rem;
font-weight: 700;
.custom-table .v-data-table__th {
position: sticky;
top: 0;
z-index: 2;
background: #f7f7f7;
}
.templates-description {
font-size: 1.1rem;
margin: 0;
opacity: 0.9;
}
.templates-content {
background: white;
border-radius: 15px;
padding: 40px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
min-height: 400px;
}
.templates-placeholder {
text-align: center;
padding: 60px 20px;
}
.placeholder-icon {
font-size: 4rem;
color: #667eea;
margin-bottom: 20px;
}
.templates-placeholder h3 {
font-size: 1.8rem;
color: #333;
margin: 0 0 15px 0;
}
.templates-placeholder p {
font-size: 1.1rem;
color: #666;
margin: 0 0 40px 0;
line-height: 1.6;
}
.placeholder-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
max-width: 600px;
margin: 0 auto;
}
.feature-item {
.name-cell {
display: flex;
align-items: center;
gap: 10px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
transition: all 0.3s ease;
gap: 8px;
}
.feature-item:hover {
background: #e9ecef;
transform: translateY(-2px);
}
.feature-item i {
font-size: 1.2rem;
color: #667eea;
}
.feature-item span {
font-weight: 600;
color: #333;
}
.feature-item-link {
text-decoration: none;
color: inherit;
}
.feature-item-link:hover {
text-decoration: none;
}
@media (max-width: 768px) {
.templates-container {
padding: 15px;
}
.templates-header {
padding: 20px;
}
.templates-header h1 {
font-size: 1.8rem;
}
.templates-description {
font-size: 1rem;
}
.templates-content {
padding: 20px;
}
.templates-placeholder {
padding: 40px 15px;
}
.placeholder-icon {
font-size: 3rem;
}
.templates-placeholder h3 {
font-size: 1.4rem;
}
.templates-placeholder p {
font-size: 1rem;
}
.placeholder-features {
grid-template-columns: 1fr;
gap: 15px;
}
}
@media (max-width: 480px) {
.templates-header h1 {
font-size: 1.5rem;
}
.templates-description {
font-size: 0.9rem;
}
.templates-placeholder h3 {
font-size: 1.2rem;
}
.templates-placeholder p {
font-size: 0.9rem;
}
.custom-table tbody tr:nth-child(odd) {
background-color: #fafafa;
}
</style>

View file

@ -65,6 +65,19 @@
<v-tabs-window-item value="1">
<v-card>
<v-card-text>
<v-row v-if="isPluginActive('custominvoice')">
<v-col cols="12" md="6">
<v-select
v-model="settings.sell.templateId"
:items="templateOptions"
:loading="templatesLoading"
clearable
label="قالب سفارشی فاکتور فروش"
hint="در صورت خالی بودن، از قالب پیش‌فرض سیستم استفاده می‌شود"
persistent-hint
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-row dense>
@ -106,6 +119,19 @@
<v-tabs-window-item value="2">
<v-card>
<v-card-text>
<v-row v-if="isPluginActive('custominvoice')">
<v-col cols="12" md="6">
<v-select
v-model="settings.buy.templateId"
:items="templateOptions"
:loading="templatesLoading"
clearable
label="قالب سفارشی فاکتور خرید"
hint="در صورت خالی بودن، از قالب پیش‌فرض سیستم استفاده می‌شود"
persistent-hint
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-row dense>
@ -141,6 +167,19 @@
<v-tabs-window-item value="3">
<v-card>
<v-card-text>
<v-row v-if="isPluginActive('custominvoice')">
<v-col cols="12" md="6">
<v-select
v-model="settings.rfbuy.templateId"
:items="templateOptions"
:loading="templatesLoading"
clearable
label="قالب سفارشی برگشت از خرید"
hint="در صورت خالی بودن، از قالب پیش‌فرض سیستم استفاده می‌شود"
persistent-hint
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-row dense>
@ -176,6 +215,19 @@
<v-tabs-window-item value="4">
<v-card>
<v-card-text>
<v-row v-if="isPluginActive('custominvoice')">
<v-col cols="12" md="6">
<v-select
v-model="settings.rfsell.templateId"
:items="templateOptions"
:loading="templatesLoading"
clearable
label="قالب سفارشی برگشت از فروش"
hint="در صورت خالی بودن، از قالب پیش‌فرض سیستم استفاده می‌شود"
persistent-hint
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-row dense>
@ -266,6 +318,8 @@ export default {
loading: ref(false),
tabs: ref(1),
plugins: [],
templatesLoading: false,
templates: [],
paperOptions: [
{ title: 'A4 افقی', value: 'A4-L' },
{ title: 'A4 عمودی', value: 'A4' },
@ -286,7 +340,8 @@ export default {
discountInfo: true,
paper: 'A4-L',
businessStamp: true,
invoiceIndex: true
invoiceIndex: true,
templateId: null
},
buy: {
pays: true,
@ -296,6 +351,7 @@ export default {
taxInfo: true,
discountInfo: true,
paper: 'A4-L',
templateId: null
},
rfbuy: {
pays: true,
@ -305,6 +361,7 @@ export default {
taxInfo: true,
discountInfo: true,
paper: 'A4-L',
templateId: null
},
rfsell: {
pays: true,
@ -314,6 +371,7 @@ export default {
taxInfo: true,
discountInfo: true,
paper: 'A4-L',
templateId: null
},
repservice: {
noteString: '',
@ -330,9 +388,22 @@ export default {
isPluginActive(plugName) {
return this.plugins[plugName] !== undefined;
},
async loadTemplates() {
if (!this.isPluginActive('custominvoice')) return;
try {
this.templatesLoading = true;
const res = await axios.post('/api/plugins/custominvoice/template/list', { page: 1, itemsPerPage: 100, scope: 'business' });
this.templates = res.data.items || [];
} finally {
this.templatesLoading = false;
}
},
getStaticData(type, key) {
return this.$store.getters.getStaticData(type, key);
},
templateTitle(t) {
return t.name + (t.isPublic ? ' (عمومی)' : '');
},
submit() {
this.loading = true;
axios.post('/api/printers/options/save', this.settings).then((response) => {
@ -347,6 +418,12 @@ export default {
})
}
},
computed: {
templateOptions() {
const base = [{ title: 'قالب پیش‌فرض سیستم', value: null }];
return base.concat(this.templates.map(t => ({ title: t.name, value: t.id })));
}
},
async beforeMount() {
this.loading = true;
axios.post("/api/printers/options/info").then((response) => {
@ -359,6 +436,7 @@ export default {
//get active plugins
axios.post('/api/plugin/get/actives',).then((response) => {
this.plugins = response.data;
this.loadTemplates();
});
}
}