Product Conversion

This commit is contained in:
2026-10-22 10:36:43 +03:30
parent 2048680d7a
commit 819e007e60
16 changed files with 1044 additions and 168 deletions
@@ -0,0 +1,507 @@
<template>
<div class="copper-convert" dir="rtl">
<p class="copper-convert__lead">
وزن دارایی را وارد کنید؛ معادل مفتول و تعداد حلقه سیم ۱.۵ و ۲.۵ با قیمت روز محاسبه می‌شود.
</p>
<a-spin :spinning="loading">
<section class="copper-convert__panel" aria-label="قیمت‌های مبنا">
<div class="copper-convert__panel-head">
<span class="copper-convert__panel-kicker">قیمت لحظه‌ای</span>
<span class="copper-convert__panel-title">مبنای محاسبه</span>
</div>
<div class="copper-convert__price-group">
<span class="copper-convert__group-label">مواد اولیه (هر کیلوگرم)</span>
<ul class="copper-convert__price-grid m-0 p-0 list-none">
<li
v-for="row in metalPriceRows"
:key="row.label"
class="copper-convert__chip"
:class="{ 'copper-convert__chip--muted': row.missing }"
>
<span class="copper-convert__chip-label">{{ row.label }}</span>
<span class="copper-convert__chip-value" dir="ltr">{{ row.value }}</span>
</li>
</ul>
</div>
<div class="copper-convert__price-group copper-convert__price-group--wire">
<span class="copper-convert__group-label">سیم (هر حلقه)</span>
<ul class="copper-convert__price-grid copper-convert__price-grid--wire m-0 p-0 list-none">
<li
v-for="row in wirePriceRows"
:key="row.label"
class="copper-convert__chip copper-convert__chip--accent"
>
<span class="copper-convert__chip-label">{{ row.label }}</span>
<span class="copper-convert__chip-value" dir="ltr">{{ row.value }}</span>
</li>
</ul>
</div>
</section>
<section class="copper-convert__form">
<label class="copper-convert__field-label">نوع دارایی</label>
<div class="copper-convert__pills" role="tablist">
<button
v-for="opt in sourceOptions"
:key="opt.value"
type="button"
role="tab"
class="copper-convert__pill"
:class="{ 'copper-convert__pill--active': source === opt.value }"
:aria-selected="source === opt.value"
@click="source = opt.value"
>
{{ opt.label }}
</button>
</div>
<div class="copper-convert__weight">
<label class="copper-convert__field-label" for="copper-convert-kg">مقدار</label>
<div class="copper-convert__weight-wrap">
<input
id="copper-convert-kg"
v-model="inputKg"
class="copper-convert__weight-input"
type="text"
inputmode="decimal"
placeholder="مثلاً ۱۰"
autocomplete="off"
/>
<span class="copper-convert__weight-unit">کیلوگرم</span>
</div>
</div>
</section>
<section v-if="result" class="copper-convert__result">
<div class="copper-convert__result-head">
<span class="copper-convert__result-kicker">خروجی</span>
<span class="copper-convert__result-title">نتیجه تبدیل</span>
</div>
<div class="copper-convert__rings">
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۱.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings15ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings15ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings15ByWeight) }} بر اساس وزن حلقه
</p>
</article>
<article class="copper-convert__ring-card">
<span class="copper-convert__ring-badge">۲.۵ mm²</span>
<p class="copper-convert__ring-count" dir="ltr">{{ formatRings(result.rings25ByValue) }}</p>
<p class="copper-convert__ring-caption">حلقه سیم</p>
<p
v-if="result.rings25ByWeight != null"
class="copper-convert__ring-sub"
dir="ltr"
>
≈ {{ formatRings(result.rings25ByWeight) }} بر اساس وزن حلقه
</p>
</article>
</div>
<div class="copper-convert__summary">
<div class="copper-convert__summary-row">
<span>ارزش معادل</span>
<strong dir="ltr">{{ formatRial(result.totalValueRial) }} <small>ریال</small></strong>
</div>
<div class="copper-convert__summary-row">
<span>معادل مفتول</span>
<strong dir="ltr">{{ formatKg(result.equivalentMaftoulKg) }} <small>کیلو</small></strong>
</div>
</div>
</section>
<p v-else-if="inputKg && !loading" class="copper-convert__warn">
مقدار معتبر وارد کنید و از فعال بودن قیمت کاتد/مفتول در سامانه مطمئن شوید.
</p>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCopperConvertPageContext } from '../composables/useCopperConvertPage'
import type { CopperConvertSource } from '@/utils/copperWireConvert'
const { loading, source, inputKg, result, priceRows } = useCopperConvertPageContext()
const sourceOptions = computed(() => [
{ label: 'کاتد', value: 'katod' as CopperConvertSource },
{ label: 'مفتول', value: 'maftoul' as CopperConvertSource },
{ label: 'شمش', value: 'shamsh' as CopperConvertSource },
])
const metalPriceRows = computed(() => priceRows.value.slice(0, 3))
const wirePriceRows = computed(() => priceRows.value.slice(3))
function formatRial(value: number) {
return Math.round(value).toLocaleString('en-US')
}
function formatKg(value: number) {
if (value >= 100) return value.toFixed(1)
if (value >= 10) return value.toFixed(2)
return value.toFixed(3)
}
function formatRings(value: number) {
if (value >= 100) return value.toFixed(1)
if (value >= 10) return value.toFixed(2)
return value.toFixed(3)
}
</script>
<style scoped>
.copper-convert {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: right;
}
.copper-convert__lead {
margin: 0 0 0.125rem;
font-size: 0.8125rem;
line-height: 1.65;
color: #64748b;
}
.copper-convert__panel {
position: relative;
padding: 0.75rem 0.875rem 0.7rem;
border-radius: 1rem;
background: linear-gradient(145deg, #0f2744 0%, #152f4f 48%, #1a365d 100%);
border: 1px solid rgba(201, 160, 98, 0.35);
box-shadow:
0 12px 28px rgba(15, 39, 68, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.copper-convert__panel::before {
content: '';
position: absolute;
top: -40px;
inset-inline-end: -30px;
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(circle, rgba(232, 180, 140, 0.28) 0%, transparent 70%);
pointer-events: none;
}
.copper-convert__panel-head {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
margin-bottom: 0.75rem;
}
.copper-convert__panel-kicker {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(201, 160, 98, 0.95);
}
.copper-convert__panel-title {
font-size: 0.9375rem;
font-weight: 800;
color: #f8fafc;
}
.copper-convert__price-group {
position: relative;
z-index: 1;
margin-bottom: 0.65rem;
}
.copper-convert__price-group--wire {
margin-bottom: 0;
padding-top: 0.65rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.copper-convert__group-label {
display: block;
margin-bottom: 0.45rem;
font-size: 0.6875rem;
font-weight: 600;
color: rgba(226, 232, 240, 0.75);
}
.copper-convert__price-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.copper-convert__price-grid--wire {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.copper-convert__chip {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
padding: 0.45rem 0.5rem;
border-radius: 0.65rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.copper-convert__chip--accent {
background: rgba(201, 160, 98, 0.12);
border-color: rgba(201, 160, 98, 0.35);
}
.copper-convert__chip--muted .copper-convert__chip-value {
color: rgba(248, 250, 252, 0.45);
}
.copper-convert__chip-label {
font-size: 0.625rem;
line-height: 1.45;
color: rgba(226, 232, 240, 0.85);
}
.copper-convert__chip-value {
font-size: 0.6875rem;
font-weight: 700;
color: #f8fafc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copper-convert__form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.125rem;
padding: 0.75rem 0.875rem;
border-radius: 1rem;
background: #faf8f5;
border: 1px solid rgba(201, 160, 98, 0.22);
}
.copper-convert__field-label {
font-size: 0.75rem;
font-weight: 700;
color: #334155;
}
.copper-convert__pills {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
padding: 0.25rem;
border-radius: 999px;
background: #eef2f6;
}
.copper-convert__pill {
appearance: none;
border: none;
border-radius: 999px;
padding: 0.5rem 0.35rem;
font-family: inherit;
font-size: 0.75rem;
font-weight: 700;
color: #64748b;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}
.copper-convert__pill--active {
color: #0f2744;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 39, 68, 0.12);
}
.copper-convert__weight-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.35rem;
padding: 0.55rem 0.75rem;
border-radius: 0.75rem;
background: #fff;
border: 1px solid rgba(15, 39, 68, 0.12);
box-shadow: inset 0 1px 2px rgba(15, 39, 68, 0.04);
}
.copper-convert__weight-input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 1.125rem;
font-weight: 800;
color: #0f2744;
text-align: right;
direction: ltr;
}
.copper-convert__weight-input::placeholder {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
}
.copper-convert__weight-unit {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 800;
color: #c9a062;
}
.copper-convert__result {
margin-top: 0.125rem;
padding: 0.875rem;
border-radius: 1rem;
background: linear-gradient(180deg, #fff 0%, #faf8f5 100%);
border: 1px solid rgba(201, 160, 98, 0.35);
box-shadow: 0 8px 24px rgba(15, 39, 68, 0.08);
}
.copper-convert__result-head {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-bottom: 0.75rem;
}
.copper-convert__result-kicker {
font-size: 0.6875rem;
font-weight: 700;
color: #c9a062;
}
.copper-convert__result-title {
font-size: 0.9375rem;
font-weight: 800;
color: #0f2744;
}
.copper-convert__rings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.copper-convert__ring-card {
position: relative;
padding: 0.75rem 0.5rem;
border-radius: 0.85rem;
text-align: center;
background: linear-gradient(160deg, #0f2744 0%, #1a365d 100%);
border: 1px solid rgba(201, 160, 98, 0.4);
overflow: hidden;
}
.copper-convert__ring-card::after {
content: '';
position: absolute;
inset-inline-start: 50%;
bottom: -24px;
width: 80px;
height: 48px;
transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(201, 160, 98, 0.35) 0%, transparent 70%);
pointer-events: none;
}
.copper-convert__ring-badge {
display: inline-block;
margin-bottom: 0.35rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 700;
color: #0f2744;
background: rgba(201, 160, 98, 0.95);
}
.copper-convert__ring-count {
margin: 0;
font-size: 1.5rem;
font-weight: 800;
line-height: 1.2;
color: #f8fafc;
}
.copper-convert__ring-caption {
margin: 0.15rem 0 0;
font-size: 0.6875rem;
font-weight: 600;
color: rgba(226, 232, 240, 0.85);
}
.copper-convert__ring-sub {
margin: 0.35rem 0 0;
font-size: 0.625rem;
color: rgba(201, 160, 98, 0.9);
}
.copper-convert__summary {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-top: 0.65rem;
border-top: 1px dashed rgba(15, 39, 68, 0.15);
}
.copper-convert__summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
color: #64748b;
}
.copper-convert__summary-row strong {
font-size: 0.8125rem;
font-weight: 800;
color: #0f2744;
}
.copper-convert__summary-row strong small {
font-size: 0.6875rem;
font-weight: 600;
color: #94a3b8;
}
.copper-convert__warn {
margin: 0;
padding: 0.65rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.75rem;
line-height: 1.6;
color: #9a3412;
background: #fff7ed;
border: 1px solid rgba(234, 88, 12, 0.2);
}
@media (max-width: 380px) {
.copper-convert__price-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,175 @@
import {
computed,
inject,
provide,
ref,
type ComputedRef,
type InjectionKey,
type Ref,
} from 'vue'
import { getProducts } from '@/api/products'
import type { Product } from '@/models/products'
import {
COPPER_CONVERT_WIRE_FALLBACK,
calcCopperWireConvert,
type CopperConvertPrices,
type CopperConvertResult,
type CopperConvertSource,
} from '@/utils/copperWireConvert'
import { LEGACY_GROUP_KATOD, LEGACY_GROUP_MAFTOL, normalizeProductGroupType } from '@/utils/productCategories'
import { getProductBuyPrice } from '@/utils/productPrice'
import { getLastPrices, startLastPricePolling, stopLastPricePolling } from '@/utils/price'
import { persianToEnglishDigits } from '@/utils/persianDigits'
export const COPPER_CONVERT_PAGE_KEY: InjectionKey<CopperConvertPageContext> =
Symbol('copperConvertPageContext')
export interface CopperConvertPageContext {
loading: Ref<boolean>
source: Ref<CopperConvertSource>
inputKg: Ref<string>
prices: ComputedRef<CopperConvertPrices>
result: ComputedRef<CopperConvertResult | null>
priceRows: ComputedRef<{ label: string; value: string; missing?: boolean }[]>
}
function parseKg(raw: string): number {
const s = persianToEnglishDigits(String(raw ?? ''))
.replace(/,/g, '')
.replace(/[^\d.]/g, '')
const n = Number.parseFloat(s)
return Number.isFinite(n) ? n : 0
}
function titleIncludes(product: Product, part: string) {
return (product.title ?? '').includes(part)
}
function isKatodProduct(product: Product) {
const group = normalizeProductGroupType(product.group_type)
return group === LEGACY_GROUP_KATOD || titleIncludes(product, 'کاتد')
}
function isMaftolProduct(product: Product) {
const group = normalizeProductGroupType(product.group_type)
return group === LEGACY_GROUP_MAFTOL || titleIncludes(product, 'مفتول')
}
function isShamshProduct(product: Product) {
const group = normalizeProductGroupType(product.group_type)
return group.includes('شمش') || titleIncludes(product, 'شمش')
}
function wireCrossSection(product: Product): '1.5' | '2.5' | null {
const t = product.title ?? ''
if (/1\.5|۱\.۵/.test(t)) return '1.5'
if (/2\.5|۲\.۵/.test(t)) return '2.5'
return null
}
function isWireProduct(product: Product) {
const t = `${product.group_type ?? ''} ${product.title ?? ''}`
return t.includes('سیم') || wireCrossSection(product) != null
}
function pickProduct(
products: Product[],
matcher: (p: Product) => boolean,
): Product | undefined {
return products.find((p) => p.active && matcher(p))
}
function resolveWireRingPrice(product: Product | undefined, fallback: number): number {
if (!product) return fallback
const price = getProductBuyPrice(product)
return price > 0 ? price : fallback
}
export function useCopperConvertPage() {
const loading = ref(true)
const source = ref<CopperConvertSource>('katod')
const inputKg = ref('')
const catalog = ref<Product[]>([])
const katodProduct = computed(() => pickProduct(catalog.value, isKatodProduct))
const maftolProduct = computed(() => pickProduct(catalog.value, isMaftolProduct))
const shamshProduct = computed(() => pickProduct(catalog.value, isShamshProduct))
const wire15Product = computed(() =>
pickProduct(catalog.value, (p) => isWireProduct(p) && wireCrossSection(p) === '1.5'),
)
const wire25Product = computed(() =>
pickProduct(catalog.value, (p) => isWireProduct(p) && wireCrossSection(p) === '2.5'),
)
const prices = computed<CopperConvertPrices>(() => ({
katodPerKg: katodProduct.value ? getProductBuyPrice(katodProduct.value) : 0,
maftoulPerKg: maftolProduct.value ? getProductBuyPrice(maftolProduct.value) : 0,
shamshPerKg: shamshProduct.value ? getProductBuyPrice(shamshProduct.value) : 0,
wireRing15: resolveWireRingPrice(wire15Product.value, COPPER_CONVERT_WIRE_FALLBACK.wireRing15),
wireRing25: resolveWireRingPrice(wire25Product.value, COPPER_CONVERT_WIRE_FALLBACK.wireRing25),
wireWeightGrams15: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams15,
wireWeightGrams25: COPPER_CONVERT_WIRE_FALLBACK.wireWeightGrams25,
}))
const result = computed(() =>
calcCopperWireConvert(parseKg(inputKg.value), source.value, prices.value),
)
const priceRows = computed(() => {
const p = prices.value
const row = (label: string, amount: number) => ({
label,
value: amount > 0 ? `${Math.round(amount).toLocaleString('en-US')} ریال` : '—',
missing: amount <= 0,
})
return [
row('قیمت کاتد (هر کیلو)', p.katodPerKg),
row('قیمت مفتول (هر کیلو)', p.maftoulPerKg),
row('قیمت شمش (هر کیلو)', p.shamshPerKg),
row('قیمت حلقه سیم ۱.۵', p.wireRing15),
row('قیمت حلقه سیم ۲.۵', p.wireRing25),
]
})
async function loadCatalog() {
loading.value = true
try {
await getLastPrices()
const list = await getProducts()
catalog.value = Array.isArray(list) ? list : []
} catch {
catalog.value = []
} finally {
loading.value = false
}
}
async function refreshLivePrices() {
startLastPricePolling()
await loadCatalog()
}
function stopLivePrices() {
stopLastPricePolling()
}
const ctx: CopperConvertPageContext = {
loading,
source,
inputKg,
prices,
result,
priceRows,
}
provide(COPPER_CONVERT_PAGE_KEY, ctx)
return { ...ctx, refreshLivePrices, stopLivePrices, loadCatalog }
}
export function useCopperConvertPageContext() {
const ctx = inject(COPPER_CONVERT_PAGE_KEY)
if (!ctx) {
throw new Error('useCopperConvertPageContext requires useCopperConvertPage')
}
return ctx
}