Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-18 18:33:38 +03:00
parent 6fc7313ae6
commit 4d8a659650
7 changed files with 984 additions and 68 deletions

View File

@@ -785,6 +785,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"order", "view", "order", "view",
http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)), http.HandlerFunc(routes.GetProductImageContentHandler(pgDB)),
) )
bindV3(r, pgDB,
"/api/order/price-list/products", "GET",
"order", "view",
wrapV3(http.HandlerFunc(routes.GetProductPricingListHandler)),
)
bindV3(r, pgDB,
"/api/order/price-list/options", "GET",
"order", "view",
wrapV3(http.HandlerFunc(routes.GetProductPricingFilterOptionsHandler)),
)
bindV3(r, pgDB,
"/api/order/price-list/campaigns", "GET",
"order", "view",
wrapV3(routes.GetWholesaleCampaignsHandler(pgDB)),
)
bindV3(r, pgDB,
"/api/order/price-list/variant-rows", "GET",
"order", "view",
wrapV3(routes.GetWholesaleCampaignVariantRowsHandler(pgDB, mssql)),
)
bindV3(r, pgDB,
"/api/order/price-list/export-excel", "POST",
"order", "view",
wrapV3(http.HandlerFunc(routes.ExportProductPriceListExcelHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/order/price-list/export-pdf", "POST",
"order", "view",
wrapV3(http.HandlerFunc(routes.ExportProductPriceListPDFHandler(pgDB))),
)
bindV3(r, pgDB, bindV3(r, pgDB,
"/api/product-size-match/rules", "GET", "/api/product-size-match/rules", "GET",
"order", "view", "order", "view",

View File

@@ -0,0 +1,51 @@
package routes
import (
"strings"
"github.com/jung-kurt/gofpdf"
)
func writePDFTableHeader(pdf *gofpdf.Fpdf, font string, heads []string, widths []float64) {
pdf.SetFont(font, "B", 5.9)
pdf.SetFillColor(235, 235, 235)
for i, h := range heads {
w := 12.0
if i < len(widths) {
w = widths[i]
}
pdf.CellFormat(w, 4.8, fitPDFCellText(pdf, h, w-1.2), "1", 0, "L", true, 0, "")
}
pdf.Ln(-1)
}
func writePDFTableRow(pdf *gofpdf.Fpdf, cells []string, widths []float64, aligns []string, height float64) {
for i, c := range cells {
w := 12.0
if i < len(widths) {
w = widths[i]
}
align := "L"
if i < len(aligns) && strings.TrimSpace(aligns[i]) != "" {
align = aligns[i]
}
pdf.CellFormat(w, height, fitPDFCellText(pdf, strings.TrimSpace(c), w-1.2), "1", 0, align, false, 0, "")
}
pdf.Ln(-1)
}
func fitPDFCellText(pdf *gofpdf.Fpdf, s string, maxWidth float64) string {
s = strings.TrimSpace(s)
if s == "" || pdf.GetStringWidth(s) <= maxWidth {
return s
}
r := []rune(s)
for len(r) > 0 {
candidate := string(r) + "..."
if pdf.GetStringWidth(candidate) <= maxWidth {
return candidate
}
r = r[:len(r)-1]
}
return ""
}

View File

@@ -1,6 +1,7 @@
package routes package routes
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -13,6 +14,8 @@ import (
"bssapp-backend/internal/mailer" "bssapp-backend/internal/mailer"
"bssapp-backend/models" "bssapp-backend/models"
"bssapp-backend/queries" "bssapp-backend/queries"
"github.com/jung-kurt/gofpdf"
) )
func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) { func loadPricingRecipients(pg *sql.DB, firstGroupCode string) ([]string, error) {
@@ -79,42 +82,77 @@ func fmtDateTRFromISO(d string) string {
return day + "." + m + "." + y return day + "." + m + "." + y
} }
func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPricing, actor string, at time.Time) string { func buildPricingChangeMailHTML(rows []models.ProductPricing, actor string, at time.Time) string {
// Keep it simple: wide, scrollable table.
var b strings.Builder var b strings.Builder
// NOTE: Mail clients often render small fonts; keep this comfortably readable.
// Use large inline sizes (some clients still downscale); keep everything inline for maximum compatibility.
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`) b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
b.WriteString(`<div style="margin-bottom:10px;">`) b.WriteString(`<div style="margin-bottom:10px;">`)
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`) b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Fiyat Degisikligi</b></div>`)
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
if strings.TrimSpace(actor) != "" { if strings.TrimSpace(actor) != "" {
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`) b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
} }
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`) b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
b.WriteString(`<div>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`) b.WriteString(`<div>Urun Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
b.WriteString(`<div style="margin-top:10px;">Detaylar ekteki PDF dosyasindadir.</div>`)
b.WriteString(`</div>`) b.WriteString(`</div>`)
b.WriteString(`</div>`)
return b.String()
}
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`) func buildPricingChangeMailPDF(rows []models.ProductPricing, actor string, at time.Time) ([]byte, error) {
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`) pdf := gofpdf.New("L", "mm", "A2", "")
b.WriteString(`<thead><tr>`) font := "Arial"
if err := registerDejavuFonts(pdf, "dejavu"); err == nil {
font = "dejavu"
} else {
log.Printf("[pricing-mail] pdf font fallback: %v", err)
}
pdf.SetMargins(7, 8, 7)
pdf.SetAutoPageBreak(true, 10)
pdf.AddPage()
pdf.SetFont(font, "B", 13)
pdf.CellFormat(0, 7, "Fiyat Degisikligi", "", 1, "L", false, 0, "")
pdf.SetFont(font, "", 8)
if strings.TrimSpace(actor) != "" {
pdf.CellFormat(0, 5, "Islem Yapan: "+strings.TrimSpace(actor), "", 1, "L", false, 0, "")
}
pdf.CellFormat(0, 5, "Tarih: "+at.Format("02.01.2006 15:04"), "", 1, "L", false, 0, "")
pdf.CellFormat(0, 5, fmt.Sprintf("Urun Sayisi: %d", len(rows)), "", 1, "L", false, 0, "")
pdf.Ln(2)
heads := []string{ heads := []string{
"MARKA GRUBU", "MARKA", "BRAND CODE", "URUN KODU", "MARKA GRUBU", "MARKA", "BRAND", "URUN KODU",
"STOK ADET", "STOK GIRIS", "SON MALIYET", "SON FIYAT", "STOK", "STOK GIRIS", "SON MALIYET", "SON FIYAT",
"ASKILI YAN", "KATEGORI", "URUN ILK GRUBU", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM", "ASKILI", "KATEGORI", "URUN ANA GRUBU", "URUN ALT GRUBU", "ICERIK", "KARISIM",
"MALIYET FIYATI", "TABAN USD", "TABAN TRY", "MALIYET", "TABAN USD", "TABAN TRY",
"USD1", "USD2", "USD3", "USD4", "USD5", "USD6", "USD1", "USD2", "USD3", "USD4", "USD5", "USD6",
"EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6", "EUR1", "EUR2", "EUR3", "EUR4", "EUR5", "EUR6",
"TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6", "TRY1", "TRY2", "TRY3", "TRY4", "TRY5", "TRY6",
} }
for _, h := range heads { widths := []float64{
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`) 20, 18, 18, 28,
12, 18, 18, 18,
14, 18, 22, 22, 20, 20,
18, 18, 18,
13, 13, 13, 13, 13, 13,
13, 13, 13, 13, 13, 13,
13, 13, 13, 13, 13, 13,
} }
b.WriteString(`</tr></thead><tbody>`) aligns := make([]string, len(heads))
for i := range aligns {
aligns[i] = "L"
}
for _, idx := range []int{4, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34} {
aligns[idx] = "R"
}
writePDFTableHeader(pdf, font, heads, widths)
pdf.SetFont(font, "", 5.7)
for _, r := range rows { for _, r := range rows {
b.WriteString(`<tr>`) if pdf.GetY() > 405 {
pdf.AddPage()
writePDFTableHeader(pdf, font, heads, widths)
pdf.SetFont(font, "", 5.7)
}
cells := []string{ cells := []string{
r.BrandGroupSec, r.BrandGroupSec,
r.Marka, r.Marka,
@@ -126,7 +164,6 @@ func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPric
fmtDateTRFromISO(r.LastPricingDate), fmtDateTRFromISO(r.LastPricingDate),
r.AskiliYan, r.AskiliYan,
r.Kategori, r.Kategori,
r.UrunIlkGrubu,
r.UrunAnaGrubu, r.UrunAnaGrubu,
r.UrunAltGrubu, r.UrunAltGrubu,
r.Icerik, r.Icerik,
@@ -138,27 +175,14 @@ func buildPricingChangeMailHTML(firstGroupCode string, rows []models.ProductPric
fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6), fmtMoneyMail(r.EUR1), fmtMoneyMail(r.EUR2), fmtMoneyMail(r.EUR3), fmtMoneyMail(r.EUR4), fmtMoneyMail(r.EUR5), fmtMoneyMail(r.EUR6),
fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6), fmtMoneyMail(r.TRY1), fmtMoneyMail(r.TRY2), fmtMoneyMail(r.TRY3), fmtMoneyMail(r.TRY4), fmtMoneyMail(r.TRY5), fmtMoneyMail(r.TRY6),
} }
for i, c := range cells { writePDFTableRow(pdf, cells, widths, aligns, 4.4)
align := "left"
// right align numeric-ish cells
if i >= 4 {
switch i {
case 4, 15, 16, 17,
18, 19, 20, 21, 22, 23,
24, 25, 26, 27, 28, 29,
30, 31, 32, 33, 34, 35:
align = "right"
}
}
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
}
b.WriteString(`</tr>`)
} }
b.WriteString(`</tbody></table></div>`) var buf bytes.Buffer
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`) if err := pdf.Output(&buf); err != nil {
b.WriteString(`</div>`) return nil, err
return b.String() }
return buf.Bytes(), nil
} }
// sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping. // sendPricingChangeMails sends one mail per UrunIlkGrubu (group) based on mk_pricing_first_group_mail mapping.
@@ -233,8 +257,18 @@ func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productC
return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode) return strings.TrimSpace(list[i].ProductCode) < strings.TrimSpace(list[j].ProductCode)
}) })
subject := fmt.Sprintf("Fiyat Degisikligi | %s | %s | %d urun", group, now.Format("02.01.2006 15:04"), len(list)) subject := fmt.Sprintf("Fiyat Degisikligi | %s | %d urun", now.Format("02.01.2006 15:04"), len(list))
html := buildPricingChangeMailHTML(group, list, actor, now) html := buildPricingChangeMailHTML(list, actor, now)
attachments := []mailer.Attachment(nil)
if data, err := buildPricingChangeMailPDF(list, actor, now); err != nil {
log.Printf("[pricing-mail] pdf build failed group=%s err=%v", group, err)
} else {
attachments = append(attachments, mailer.Attachment{
FileName: fmt.Sprintf("fiyat-degisikligi-%s.pdf", now.Format("20060102-1504")),
ContentType: "application/pdf",
Data: data,
})
}
// Retry 2 times with backoff. // Retry 2 times with backoff.
backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond} backoff := []time.Duration{800 * time.Millisecond, 2500 * time.Millisecond}
@@ -248,6 +282,7 @@ func sendPricingChangeMails(bg context.Context, ml *mailer.GraphMailer, productC
To: recipients, To: recipients,
Subject: subject, Subject: subject,
BodyHTML: html, BodyHTML: html,
Attachments: attachments,
}) })
stepCancel() stepCancel()
if err == nil { if err == nil {

View File

@@ -1,6 +1,7 @@
package routes package routes
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
@@ -14,6 +15,7 @@ import (
"bssapp-backend/models" "bssapp-backend/models"
"bssapp-backend/queries" "bssapp-backend/queries"
"github.com/jung-kurt/gofpdf"
"github.com/lib/pq" "github.com/lib/pq"
) )
@@ -31,22 +33,43 @@ type wholesaleCampaignMailRow struct {
DiscountRate float64 DiscountRate float64
} }
func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesaleCampaignMailRow, actor string, at time.Time) string { func buildWholesaleCampaignChangeMailHTML(rows []wholesaleCampaignMailRow, actor string, at time.Time) string {
var b strings.Builder var b strings.Builder
b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`) b.WriteString(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
b.WriteString(`<div style="margin-bottom:10px;">`) b.WriteString(`<div style="margin-bottom:10px;">`)
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`) b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`)
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
if strings.TrimSpace(actor) != "" { if strings.TrimSpace(actor) != "" {
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`) b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
} }
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`) b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`) b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
b.WriteString(`<div style="margin-top:10px;">Detaylar ekteki PDF dosyasindadir.</div>`)
b.WriteString(`</div>`) b.WriteString(`</div>`)
b.WriteString(`</div>`)
return b.String()
}
func buildWholesaleCampaignChangeMailPDF(rows []wholesaleCampaignMailRow, actor string, at time.Time) ([]byte, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
font := "Arial"
if err := registerDejavuFonts(pdf, "dejavu"); err == nil {
font = "dejavu"
} else {
log.Printf("[campaign-mail] pdf font fallback: %v", err)
}
pdf.SetMargins(7, 8, 7)
pdf.SetAutoPageBreak(true, 10)
pdf.AddPage()
pdf.SetFont(font, "B", 13)
pdf.CellFormat(0, 7, "Kampanya Degisikligi", "", 1, "L", false, 0, "")
pdf.SetFont(font, "", 8)
if strings.TrimSpace(actor) != "" {
pdf.CellFormat(0, 5, "Islem Yapan: "+strings.TrimSpace(actor), "", 1, "L", false, 0, "")
}
pdf.CellFormat(0, 5, "Tarih: "+at.Format("02.01.2006 15:04"), "", 1, "L", false, 0, "")
pdf.CellFormat(0, 5, fmt.Sprintf("Varyant Sayisi: %d", len(rows)), "", 1, "L", false, 0, "")
pdf.Ln(2)
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
b.WriteString(`<thead><tr>`)
heads := []string{ heads := []string{
"MARKA GRUBU", "MARKA GRUBU",
"MARKA", "MARKA",
@@ -56,13 +79,17 @@ func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesal
"KAMPANYA", "KAMPANYA",
"IND %", "IND %",
} }
for _, h := range heads { widths := []float64{35, 26, 35, 20, 20, 118, 16}
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`) aligns := []string{"L", "L", "L", "R", "R", "L", "R"}
} writePDFTableHeader(pdf, font, heads, widths)
b.WriteString(`</tr></thead><tbody>`)
pdf.SetFont(font, "", 7)
for _, r := range rows { for _, r := range rows {
b.WriteString(`<tr>`) if pdf.GetY() > 190 {
pdf.AddPage()
writePDFTableHeader(pdf, font, heads, widths)
pdf.SetFont(font, "", 7)
}
campaignLabel := strings.TrimSpace(r.CampaignCode) campaignLabel := strings.TrimSpace(r.CampaignCode)
if t := strings.TrimSpace(r.CampaignTitle); t != "" { if t := strings.TrimSpace(r.CampaignTitle); t != "" {
if campaignLabel != "" { if campaignLabel != "" {
@@ -85,20 +112,14 @@ func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesal
campaignLabel, campaignLabel,
fmt.Sprintf("%.2f", r.DiscountRate), fmt.Sprintf("%.2f", r.DiscountRate),
} }
for i, c := range cells { writePDFTableRow(pdf, cells, widths, aligns, 5)
align := "left"
if i == 3 || i == 4 || i == 6 {
align = "right"
}
b.WriteString(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
}
b.WriteString(`</tr>`)
} }
b.WriteString(`</tbody></table></div>`) var buf bytes.Buffer
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`) if err := pdf.Output(&buf); err != nil {
b.WriteString(`</div>`) return nil, err
return b.String() }
return buf.Bytes(), nil
} }
// sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables. // sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables.
@@ -334,14 +355,25 @@ ORDER BY dim_id, updated_at DESC;
return list[i].Dim3 < list[j].Dim3 return list[i].Dim3 < list[j].Dim3
}) })
subject := fmt.Sprintf("Kampanya Degisikligi | %s | %s | %d varyant", group, now.Format("02.01.2006 15:04"), len(list)) subject := fmt.Sprintf("Kampanya Degisikligi | %s | %d varyant", now.Format("02.01.2006 15:04"), len(list))
html := buildWholesaleCampaignChangeMailHTML(group, list, actor, now) html := buildWholesaleCampaignChangeMailHTML(list, actor, now)
attachments := []mailer.Attachment(nil)
if data, err := buildWholesaleCampaignChangeMailPDF(list, actor, now); err != nil {
log.Printf("[campaign-mail] pdf build failed group=%s err=%v", group, err)
} else {
attachments = append(attachments, mailer.Attachment{
FileName: fmt.Sprintf("kampanya-degisikligi-%s.pdf", now.Format("20060102-1504")),
ContentType: "application/pdf",
Data: data,
})
}
stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second) stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
err = ml.Send(stepCtx, mailer.Message{ err = ml.Send(stepCtx, mailer.Message{
To: recipients, To: recipients,
Subject: subject, Subject: subject,
BodyHTML: html, BodyHTML: html,
Attachments: attachments,
}) })
stepCancel() stepCancel()
if err != nil { if err != nil {

View File

@@ -294,6 +294,11 @@ const menuItems = [
to: '/app/order-gateway', to: '/app/order-gateway',
permission: 'order:view' permission: 'order:view'
}, },
{
label: 'Fiyat Listesi',
to: '/app/order-price-list',
permission: 'order:view'
},
{ {
label: 'Üretime Verilen Siparişleri Güncelle', label: 'Üretime Verilen Siparişleri Güncelle',
to: '/app/orderproductionupdate', to: '/app/orderproductionupdate',

View File

@@ -0,0 +1,757 @@
<template>
<q-page class="q-pa-xs pricing-page order-price-list-page">
<teleport to="body">
<div
v-if="pageBusy"
class="page-busy-overlay"
@click.stop
@mousedown.stop
@mouseup.stop
@touchstart.stop
@wheel.stop
>
<q-spinner-gears size="56px" color="primary" />
<div class="page-busy-label">Yukleniyor...</div>
</div>
</teleport>
<div class="top-bar row items-center justify-between q-mb-xs">
<div class="text-subtitle1 text-weight-bold">Fiyat Listesi</div>
<div class="top-actions">
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--filters">
<q-select
v-model="topUrunIlkGrubu"
dense
outlined
clearable
emit-value
map-options
use-input
:options="topUrunIlkGrubuOptions"
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
:disable="pageBusy"
label="Urun Ilk Grubu"
style="min-width: 220px"
@filter="onTopFilterSearchUrunIlkGrubu"
@update:model-value="onTopUrunIlkGrubuChange"
/>
<q-select
v-model="topUrunAnaGrubu"
dense
outlined
clearable
emit-value
map-options
use-input
:options="topUrunAnaGrubuOptions"
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
:disable="pageBusy"
label="Urun Ana Grubu"
style="min-width: 240px"
@filter="onTopFilterSearchUrunAnaGrubu"
@update:model-value="onTopUrunAnaGrubuChange"
/>
<q-select
v-model="selectedProductCodes"
dense
outlined
multiple
use-chips
use-input
emit-value
map-options
:options="productCodeOptions"
:loading="Boolean(serverFilterLoading.productCode)"
:disable="pageBusy"
label="Urun Kodu"
style="min-width: 320px"
@filter="onProductCodeSearch"
/>
<q-btn
color="primary"
icon="filter_alt"
label="Listeyi Getir"
:disable="pageBusy || !canFetch"
:loading="loading"
@click="reloadData({ page: 1 })"
/>
<q-btn
flat
color="grey-7"
icon="restart_alt"
label="Secimleri Sifirla"
:disable="pageBusy"
@click="resetSelections"
/>
</div>
<div class="row items-center q-gutter-xs top-actions-row top-actions-row--actions">
<q-btn-dropdown dense color="secondary" outline icon="view_module" label="Fiyat Secimi" :auto-close="false" :disable="pageBusy">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllPrices">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllPrices">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in priceOptions" :key="option.value" clickable @click="togglePriceOption(option.value)">
<q-item-section avatar>
<q-checkbox
dense
:model-value="selectedPriceSet.has(option.value)"
:disable="pageBusy"
@click.stop
@update:model-value="() => togglePriceOption(option.value)"
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
dense
flat
color="grey-8"
icon="view_sidebar"
:label="leftDetailsExpanded ? 'Detaylari Gizle' : 'Detaylari Goster'"
:disable="pageBusy"
@click="leftDetailsExpanded = !leftDetailsExpanded"
/>
<q-btn-dropdown dense color="primary" outline icon="download" label="Cikti Al" :disable="pageBusy || filteredRows.length === 0">
<q-list dense style="min-width: 220px;">
<q-item clickable @click="exportVisibleExcel">
<q-item-section avatar><q-icon name="grid_on" /></q-item-section>
<q-item-section>Excel'e Aktar</q-item-section>
</q-item>
<q-item clickable @click="printVisibleRows">
<q-item-section avatar><q-icon name="picture_as_pdf" /></q-item-section>
<q-item-section>PDF / Yazdir</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-space />
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
:disable="pageBusy"
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, totalPages || 1) }} - {{ filteredRows.length }} satir
</div>
</div>
</div>
</div>
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
<div v-if="showGuidanceOverlay" class="empty-overlay">
<div class="empty-overlay-inner">
<div class="text-subtitle1 text-weight-bold">Liste Icin Filtre Secin</div>
<div class="text-body2 q-mt-xs">
Urun Ilk Grubu, Urun Ana Grubu veya Urun Kodu secip <b>LISTEYI GETIR</b>'e basin.
</div>
</div>
</div>
<q-table
class="pane-table pricing-table order-price-list-table"
flat
dense
row-key="rowKey"
:rows="filteredRows"
:columns="visibleColumns"
:loading="loading"
:rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }"
hide-bottom
:table-style="tableStyle"
>
<template #body-cell-image="props">
<q-td :props="props" class="image-cell">
<q-img
v-if="props.row.imageUrl"
:src="props.row.imageUrl"
class="product-thumb"
fit="cover"
no-spinner
/>
</q-td>
</template>
<template #body-cell-campaignLabel="props">
<q-td :props="props">
<q-badge v-if="props.row.campaignLabel" color="primary" outline :label="props.row.campaignLabel" />
<span v-else class="text-grey-6">-</span>
</q-td>
</template>
<template v-for="name in priceColumnNames" #[`body-cell-${name}`]="props" :key="name">
<q-td :props="props" class="text-right">
{{ formatPrice(props.row[name]) }}
</q-td>
</template>
</q-table>
</div>
</q-page>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
const PAGE_LIMIT = 250
const GUIDANCE_MSG = 'Liste icin filtre secin.'
const priceOptions = ['USD', 'EUR', 'TRY'].flatMap((cur) => [1, 2, 3, 4, 5, 6].map((lv) => ({
label: `${cur} ${lv}`,
value: `${cur.toLowerCase()}${lv}`
})))
const campaignPairs = priceOptions.map((x) => ({ base: x.value, derived: `${x.value}Campaign` }))
const priceColumnNames = campaignPairs.flatMap((p) => [p.base, p.derived])
const topUrunIlkGrubu = ref(null)
const topUrunAnaGrubu = ref(null)
const selectedProductCodes = ref([])
const selectedPriceOptions = ref(priceOptions.map((x) => x.value))
const leftDetailsExpanded = ref(true)
const rows = ref([])
const loading = ref(false)
const renderPending = ref(false)
const error = ref(GUIDANCE_MSG)
const currentPage = ref(1)
const totalPages = ref(1)
const totalCount = ref(0)
const serverFilterOptionMap = ref({})
const serverFilterLoading = ref({})
const serverFilterLastQuery = ref({})
const filterSearch = ref({ productCode: '', urunIlkGrubu: '', urunAnaGrubu: '' })
const imageCache = new Map()
const selectedPriceSet = computed(() => new Set(selectedPriceOptions.value || []))
const pageBusy = computed(() => loading.value || renderPending.value)
const canFetch = computed(() => Boolean(topUrunIlkGrubu.value || topUrunAnaGrubu.value || selectedProductCodes.value.length > 0))
const showGuidanceOverlay = computed(() => !loading.value && rows.value.length === 0 && error.value === GUIDANCE_MSG)
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
const productCodeOptions = computed(() => serverFilterOptionMap.value.productCode || [])
function toText (value) {
return String(value ?? '').trim()
}
function toNumber (value) {
const n = Number(String(value ?? '0').replace(/\./g, '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
function round2 (value) {
const n = Number(value)
return Number.isFinite(n) ? Number(n.toFixed(2)) : null
}
function formatPrice (value) {
const n = Number(value)
if (!Number.isFinite(n) || n === 0) return ''
return n.toLocaleString('tr-TR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function formatStock (value) {
const n = Number(value)
if (!Number.isFinite(n)) return ''
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
}
function mapProductRow (raw, index) {
const row = {
id: index + 1,
productCode: toText(raw?.ProductCode),
stockQty: toNumber(raw?.StockQty),
stockEntryDate: toText(raw?.StockEntryDate),
lastPricingDate: toText(raw?.LastPricingDate),
askiliYan: toText(raw?.AskiliYan),
kategori: toText(raw?.Kategori),
urunIlkGrubu: toText(raw?.UrunIlkGrubu),
urunAnaGrubu: toText(raw?.UrunAnaGrubu),
urunAltGrubu: toText(raw?.UrunAltGrubu),
icerik: toText(raw?.Icerik),
karisim: toText(raw?.Karisim),
marka: toText(raw?.Marka),
brandGroupSelection: toText(raw?.BrandGroupSec)
}
for (const p of priceOptions) row[p.value] = toNumber(raw?.[p.value.toUpperCase()])
return row
}
function applyCampaignPrices (row) {
const rate = Number(row?.campaignRate || 0)
for (const p of campaignPairs) {
const base = Number(row?.[p.base] || 0)
row[p.derived] = rate > 0 && base > 0 ? round2(base * (1 - rate / 100)) : null
}
}
function buildCampaignLabel (variant) {
const code = toText(variant?.campaign_code)
const title = toText(variant?.campaign_title)
if (code && title) return `${code} - ${title}`
return code || title
}
function buildRows (products, variants) {
const byCode = new Map()
for (const v of variants || []) {
const code = toText(v?.product_code)
if (!code) continue
if (!byCode.has(code)) byCode.set(code, [])
byCode.get(code).push(v)
}
const out = []
for (const p of products) {
const list = byCode.get(p.productCode) || []
if (list.length === 0) {
const row = {
...p,
rowKey: `${p.productCode}|0|0`,
imageUrl: '',
variantCodes: '',
variantStocks: formatStock(p.stockQty),
campaignLabel: '',
campaignRate: null,
lastCampaignDate: ''
}
applyCampaignPrices(row)
out.push(row)
continue
}
list.sort((a, b) => toText(a?.variant_code).localeCompare(toText(b?.variant_code), 'tr'))
for (const v of list) {
const d1 = Number(v?.dim1 || 0)
const d3 = v?.dim3 == null ? 0 : Number(v?.dim3 || 0)
const row = {
...p,
rowKey: `${p.productCode}|${d1}|${d3}`,
imageUrl: '',
dim1: d1,
dim3: d3,
variantCodes: toText(v?.variant_code),
variantStocks: formatStock(v?.stock_qty),
stockQty: Number(v?.stock_qty ?? 0),
campaignLabel: buildCampaignLabel(v),
campaignRate: Number(v?.discount_rate || 0) || null,
lastCampaignDate: toText(v?.campaign_last_dttm)
}
applyCampaignPrices(row)
out.push(row)
}
}
return out
}
async function fetchServerFilterOptions (field, q = '') {
const query = toText(q)
const last = toText(serverFilterLastQuery.value[field])
const cached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
if (cached && last === query) return
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: query }
try {
const params = { field, q: query, limit: field === 'productCode' ? 200 : 160 }
if (field === 'urunAnaGrubu' && topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
const res = await api.get('/order/price-list/options', { params })
const items = Array.isArray(res?.data?.items) ? res.data.items : []
serverFilterOptionMap.value = {
...serverFilterOptionMap.value,
[field]: items.map((x) => ({
label: toText(x?.label ?? x?.value),
value: toText(x?.value ?? x?.label)
})).filter((x) => x.value)
}
} finally {
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
}
}
function onTopFilterSearchUrunIlkGrubu (val, update) {
update(() => {
filterSearch.value.urunIlkGrubu = toText(val)
void fetchServerFilterOptions('urunIlkGrubu', val)
})
}
function onTopFilterSearchUrunAnaGrubu (val, update) {
update(() => {
filterSearch.value.urunAnaGrubu = toText(val)
void fetchServerFilterOptions('urunAnaGrubu', val)
})
}
function onProductCodeSearch (val, update) {
update(() => {
filterSearch.value.productCode = toText(val)
void fetchServerFilterOptions('productCode', val)
})
}
function onTopUrunIlkGrubuChange () {
topUrunAnaGrubu.value = null
void fetchServerFilterOptions('urunAnaGrubu', '')
}
function onTopUrunAnaGrubuChange () {}
function requestParams (page) {
const params = {
page,
limit: Math.min(500, Math.max(PAGE_LIMIT, selectedProductCodes.value.length || 0)),
include_total: 1
}
if (topUrunIlkGrubu.value) params.urun_ilk_grubu = topUrunIlkGrubu.value
if (topUrunAnaGrubu.value) params.urun_ana_grubu = topUrunAnaGrubu.value
if (selectedProductCodes.value.length > 0) params.product_code = selectedProductCodes.value.join(',')
return params
}
async function reloadData ({ page = 1 } = {}) {
if (!canFetch.value) {
rows.value = []
error.value = GUIDANCE_MSG
return
}
loading.value = true
renderPending.value = true
try {
const productRes = await api.request({
method: 'GET',
url: '/order/price-list/products',
params: requestParams(page),
timeout: 180000
})
const products = (Array.isArray(productRes?.data) ? productRes.data : []).map(mapProductRow)
totalCount.value = Number(productRes?.headers?.['x-total-count'] || products.length || 0)
totalPages.value = Math.max(1, Number(productRes?.headers?.['x-total-pages'] || 1))
currentPage.value = Math.max(1, Number(productRes?.headers?.['x-page'] || page))
const codes = products.map((x) => x.productCode).filter(Boolean)
let variants = []
if (codes.length > 0) {
const variantRes = await api.request({
method: 'GET',
url: '/order/price-list/variant-rows',
params: { product_code: codes.join(',') },
timeout: 180000
})
variants = Array.isArray(variantRes?.data) ? variantRes.data : []
}
rows.value = buildRows(products, variants)
error.value = ''
void loadImagesForRows(rows.value.slice(0, 120))
await nextTick()
} catch (err) {
rows.value = []
error.value = err?.response?.data || err?.message || 'Fiyat listesi alinamadi'
Notify.create({ type: 'negative', message: error.value })
} finally {
loading.value = false
setTimeout(() => { renderPending.value = false }, 120)
}
}
async function loadImagesForRows (list) {
const targets = []
const seen = new Set()
for (const row of list) {
const key = `${row.productCode}|${row.dim1 || 0}|${row.dim3 || 0}`
if (!row.productCode || seen.has(key)) continue
seen.add(key)
targets.push({ row, key })
}
await Promise.all(targets.map(async ({ row, key }) => {
if (imageCache.has(key)) {
row.imageUrl = imageCache.get(key)
return
}
try {
const res = await api.get('/product-images', {
params: {
code: row.productCode,
dim1_id: row.dim1 || '',
dim3_id: row.dim3 || ''
},
timeout: 15000
})
const first = Array.isArray(res?.data) ? res.data[0] : null
const url = toText(first?.thumb_url || first?.content_url || first?.full_url)
imageCache.set(key, url)
row.imageUrl = url
} catch {
imageCache.set(key, '')
}
}))
}
function resetSelections () {
topUrunIlkGrubu.value = null
topUrunAnaGrubu.value = null
selectedProductCodes.value = []
rows.value = []
error.value = GUIDANCE_MSG
currentPage.value = 1
totalPages.value = 1
totalCount.value = 0
}
function onPageChange (page) {
void reloadData({ page })
}
function togglePriceOption (value) {
const set = new Set(selectedPriceOptions.value || [])
if (set.has(value)) set.delete(value)
else set.add(value)
selectedPriceOptions.value = priceOptions.map((x) => x.value).filter((x) => set.has(x))
}
function selectAllPrices () {
selectedPriceOptions.value = priceOptions.map((x) => x.value)
}
function clearAllPrices () {
selectedPriceOptions.value = []
}
function col (name, label, field, width, extra = {}) {
return {
name,
label,
field,
align: extra.align || 'left',
sortable: !!extra.sortable,
style: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
headerStyle: `width:${width}px;min-width:${width}px;max-width:${width}px;`,
classes: extra.classes || '',
headerClasses: extra.headerClasses || extra.classes || ''
}
}
const allColumns = [
col('image', '', 'imageUrl', 58, { align: 'center', classes: 'image-col sticky-col' }),
col('brandGroupSelection', 'MARKA GRUBU', 'brandGroupSelection', 86, { classes: 'ps-col sticky-col' }),
col('marka', 'MARKA', 'marka', 62, { sortable: true, classes: 'ps-col sticky-col' }),
col('productCode', 'URUN KODU', 'productCode', 112, { sortable: true, classes: 'ps-col product-code-col sticky-col' }),
col('variantCodes', 'VARYANT', 'variantCodes', 112, { classes: 'ps-col variant-col sticky-col' }),
col('variantStocks', 'STOK', 'stockQty', 62, { align: 'right', sortable: true, classes: 'ps-col variant-stock-col sticky-col' }),
col('campaignLabel', 'KAMPANYA', 'campaignLabel', 150, { classes: 'ps-col campaign-col sticky-col' }),
col('campaignRate', 'IND %', 'campaignRate', 58, { align: 'right', classes: 'ps-col campaign-rate-col sticky-col' }),
col('stockEntryDate', 'STOK GIRIS', 'stockEntryDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastPricingDate', 'SON FIYAT', 'lastPricingDate', 86, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastCampaignDate', 'SON KAMPANYA', 'lastCampaignDate', 98, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('askiliYan', 'ASKILI YAN', 'askiliYan', 58, { sortable: true, classes: 'ps-col' }),
col('kategori', 'KATEGORI', 'kategori', 58, { sortable: true, classes: 'ps-col' }),
col('urunIlkGrubu', 'URUN ILK GRUBU', 'urunIlkGrubu', 70, { sortable: true, classes: 'ps-col' }),
col('urunAnaGrubu', 'URUN ANA GRUBU', 'urunAnaGrubu', 74, { sortable: true, classes: 'ps-col' }),
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 74, { sortable: true, classes: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 66, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 66, { sortable: true, classes: 'ps-col' }),
...campaignPairs.flatMap((p) => [
col(p.base, p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2'), p.base, 78, { align: 'right', classes: `${p.base.slice(0, 3)}-col` }),
col(p.derived, `${p.base.toUpperCase().replace(/([A-Z]+)(\d)/, '$1 $2')} KMP`, p.derived, 88, { align: 'right', classes: `${p.base.slice(0, 3)}-col campaign-price-col` })
])
]
const hideableLeftDetailColumnNames = new Set([
'stockEntryDate',
'lastPricingDate',
'lastCampaignDate',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
])
const visibleColumns = computed(() => allColumns.filter((c) => {
if (/^(usd|eur|try)[1-6]$/.test(c.name)) return selectedPriceSet.value.has(c.name)
if (/^(usd|eur|try)[1-6]Campaign$/.test(c.name)) return selectedPriceSet.value.has(c.name.replace(/Campaign$/, ''))
if (!leftDetailsExpanded.value && hideableLeftDetailColumnNames.has(c.name)) return false
return true
}))
const filteredRows = computed(() => rows.value || [])
const tableMinWidth = computed(() => visibleColumns.value.reduce((sum, c) => sum + extractWidth(c.style), 0))
const tableStyle = computed(() => ({
width: `${tableMinWidth.value}px`,
minWidth: `${tableMinWidth.value}px`,
tableLayout: 'fixed'
}))
const stickyScrollComp = computed(() => 650)
function extractWidth (style) {
const m = String(style || '').match(/width:(\d+)px/)
return m ? Number(m[1]) : 80
}
function exportCell (row, col) {
if (col.name === 'image') return row.imageUrl || ''
if (priceColumnNames.includes(col.name)) return formatPrice(row[col.field])
if (col.name === 'variantStocks') return formatStock(row.stockQty)
if (col.name === 'campaignRate') return row.campaignRate ? formatPrice(row.campaignRate) : ''
return toText(row[col.field])
}
function exportVisibleExcel () {
const cols = visibleColumns.value
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" width="54" height="54"></td>`
return `<td>${escapeHtml(exportCell(row, c))}</td>`
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body><table border="1"><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table></body></html>`
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `fiyat_listesi_${new Date().toISOString().slice(0, 10)}.xls`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
function printVisibleRows () {
const cols = visibleColumns.value
const body = filteredRows.value.map((row) => `<tr>${cols.map((c) => {
if (c.name === 'image' && row.imageUrl) return `<td><img src="${row.imageUrl}" class="thumb"></td>`
return `<td class="${priceColumnNames.includes(c.name) ? 'num' : ''}">${escapeHtml(exportCell(row, c))}</td>`
}).join('')}</tr>`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Fiyat Listesi</title><style>
@page { size: A3 landscape; margin: 8mm; }
body { font-family: Arial, sans-serif; font-size: 8px; }
h1 { font-size: 16px; margin: 0 0 8px; }
table { border-collapse: collapse; width: 100%; }
th { background: #957116; color: #fff; }
th, td { border: 1px solid #ccc; padding: 3px; vertical-align: middle; }
.num { text-align: right; }
.thumb { width: 42px; height: 42px; object-fit: cover; }
</style></head><body><h1>Fiyat Listesi</h1><table><thead><tr>${cols.map((c) => `<th>${escapeHtml(c.label || 'Gorsel')}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table><script>window.onload=function(){window.print()}<\/script></body></html>`
const win = window.open('', '_blank')
if (!win) return
win.document.open()
win.document.write(html)
win.document.close()
}
function escapeHtml (value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
watch(selectedProductCodes, (list) => {
const clean = Array.isArray(list) ? [...new Set(list.map(toText).filter(Boolean))] : []
if (clean.join('\u0001') !== (Array.isArray(list) ? list.join('\u0001') : '')) {
selectedProductCodes.value = clean
}
})
onMounted(() => {
void fetchServerFilterOptions('urunIlkGrubu', '')
void fetchServerFilterOptions('urunAnaGrubu', '')
void fetchServerFilterOptions('productCode', '')
})
</script>
<style scoped>
.order-price-list-page {
position: relative;
}
.top-actions {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.top-actions-row {
width: 100%;
justify-content: flex-end;
}
.table-wrap {
position: relative;
overflow: auto;
height: calc(100vh - 150px);
}
.product-thumb {
width: 46px;
height: 46px;
border-radius: 4px;
background: #f4f4f4;
}
.image-cell {
padding: 2px 4px !important;
}
.order-price-list-table :deep(td),
.order-price-list-table :deep(th) {
font-size: 11px;
}
.order-price-list-table :deep(.campaign-price-col) {
background: #f6fbf7;
}
.page-busy-overlay {
position: fixed;
inset: 0;
z-index: 30000;
background: rgba(255, 255, 255, 0.68);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
pointer-events: all;
}
.page-busy-label {
font-weight: 600;
color: #4c4c4c;
}
.empty-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
}
.empty-overlay-inner {
border: 1px solid #ddd;
background: #fff;
border-radius: 6px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -317,6 +317,12 @@ const routes = [
component: () => import('pages/OrderBulkClose.vue'), component: () => import('pages/OrderBulkClose.vue'),
meta: { permission: 'order:update' } meta: { permission: 'order:update' }
}, },
{
path: 'order-price-list',
name: 'order-price-list',
component: () => import('pages/OrderPriceList.vue'),
meta: { permission: 'order:view' }
},
{ {
path: 'order-pdf/:id', path: 'order-pdf/:id',