587 lines
19 KiB
Svelte
587 lines
19 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { onMount } from 'svelte';
|
||
|
|
import { page } from '$app/stores';
|
||
|
|
import { goto } from '$app/navigation';
|
||
|
|
import { get } from 'svelte/store';
|
||
|
|
import { addNotification } from '$lib/core/stores/noti.js';
|
||
|
|
import { departmentStore } from '$lib/core/stores/departments.js';
|
||
|
|
import { permission as currentPerms } from '$lib/core/stores/permissions.js';
|
||
|
|
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||
|
|
import {
|
||
|
|
clearSheetPriceSentTypes,
|
||
|
|
getCountryPrimaryLanguage,
|
||
|
|
getPriceFromCells,
|
||
|
|
lastRequestSheetPrice,
|
||
|
|
sheetPriceLoading,
|
||
|
|
type PriceSlot,
|
||
|
|
type PriceSlotProduct
|
||
|
|
} from '$lib/core/stores/sheetStore.js';
|
||
|
|
import { requestSheetPrice } from '$lib/core/services/sheetService.js';
|
||
|
|
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||
|
|
|
||
|
|
import Button from '$lib/components/ui/button/button.svelte';
|
||
|
|
import Input from '$lib/components/ui/input/input.svelte';
|
||
|
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||
|
|
import * as Select from '$lib/components/ui/select/index.js';
|
||
|
|
import * as Table from '$lib/components/ui/table/index.js';
|
||
|
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||
|
|
import { Calculator, RefreshCw, Save, RotateCcw, Search } from '@lucide/svelte/icons';
|
||
|
|
|
||
|
|
type AdjustmentMode =
|
||
|
|
| 'increase_percent'
|
||
|
|
| 'increase_amount'
|
||
|
|
| 'decrease_amount'
|
||
|
|
| 'decrease_percent';
|
||
|
|
|
||
|
|
const adjustmentModeLabels: Record<AdjustmentMode, string> = {
|
||
|
|
increase_percent: 'Increase by Percentage (%)',
|
||
|
|
increase_amount: 'Increase by Fixed Amount',
|
||
|
|
decrease_amount: 'Decrease by Fixed Amount',
|
||
|
|
decrease_percent: 'Decrease by Percentage (%)'
|
||
|
|
};
|
||
|
|
|
||
|
|
const mockProducts: PriceSlotProduct[] = [
|
||
|
|
{
|
||
|
|
product_code: '12-01-01-0001',
|
||
|
|
name: 'HOT ESPRESSO | เอสเพรสโซ่ร้อน',
|
||
|
|
price: 30,
|
||
|
|
row_index: 2
|
||
|
|
},
|
||
|
|
{ product_code: '12-01-01-0003', name: 'HOT AMERICANO | กาแฟดำร้อน', price: 35, row_index: 3 },
|
||
|
|
{ product_code: '12-01-01-0004', name: 'HOT LATTE | ลาเต้ร้อน', price: 40, row_index: 5 },
|
||
|
|
{ product_code: '12-01-01-0006', name: 'HOT MOCHA | มอคค่าร้อน', price: 55, row_index: 7 },
|
||
|
|
{
|
||
|
|
product_code: '12-01-02-0001',
|
||
|
|
name: 'Iced AMERICANO | กาแฟดำเย็น',
|
||
|
|
price: 40,
|
||
|
|
row_index: 16
|
||
|
|
},
|
||
|
|
{ product_code: '12-01-02-0002', name: 'ICED LATTE | ลาเต้เย็น', price: 50, row_index: 17 },
|
||
|
|
{ product_code: '12-01-02-0003', name: 'ICED MOCHA | มอคค่าเย็น', price: 60, row_index: 18 },
|
||
|
|
{
|
||
|
|
product_code: '12-02-01-0002',
|
||
|
|
name: 'Hot THAI MILK TEA | ชาไทยร้อน',
|
||
|
|
price: 40,
|
||
|
|
row_index: 27
|
||
|
|
},
|
||
|
|
{
|
||
|
|
product_code: '12-02-01-0004',
|
||
|
|
name: 'Hot MATCHA LATTE | มัทฉะลาเต้ร้อน',
|
||
|
|
price: 50,
|
||
|
|
row_index: 29
|
||
|
|
}
|
||
|
|
];
|
||
|
|
|
||
|
|
function buildMockSlots(): PriceSlot[] {
|
||
|
|
return Array.from({ length: 10 }, (_, index) => {
|
||
|
|
const slot = index + 1;
|
||
|
|
const increase = slot === 1 ? 15 : slot === 2 ? 25 : slot * 5;
|
||
|
|
|
||
|
|
return {
|
||
|
|
slot,
|
||
|
|
name: slot <= 2 ? `ProfileIncrease${increase}` : `PriceSlot${slot}`,
|
||
|
|
description: slot <= 2 ? `increase price ${increase}%` : '',
|
||
|
|
products: mockProducts.map((product) => ({
|
||
|
|
...product,
|
||
|
|
price:
|
||
|
|
product.price === null
|
||
|
|
? null
|
||
|
|
: slot <= 2
|
||
|
|
? Math.ceil((product.price * (1 + increase / 100)) / 5) * 5
|
||
|
|
: product.price
|
||
|
|
}))
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||
|
|
let enabledCountries = $state<string[]>([]);
|
||
|
|
let selectedSlot = $state(1);
|
||
|
|
const initialSlots = buildMockSlots();
|
||
|
|
let slots = $state<PriceSlot[]>(initialSlots);
|
||
|
|
let savedSnapshot = $state<PriceSlot[]>(structuredClone(initialSlots));
|
||
|
|
let loading = $state(false);
|
||
|
|
let productCodeSearch = $state('');
|
||
|
|
let createDialogOpen = $state(false);
|
||
|
|
let adjustmentMode = $state<AdjustmentMode>('increase_percent');
|
||
|
|
let adjustmentValue = $state(15);
|
||
|
|
let createName = $state('ProfileIncrease15');
|
||
|
|
let createDescription = $state('increase price 15%');
|
||
|
|
|
||
|
|
let currentSlot = $derived(slots.find((slot) => slot.slot === selectedSlot) ?? slots[0]);
|
||
|
|
let selectedCountryLanguage = $derived(getCountryPrimaryLanguage(selectedCountry));
|
||
|
|
let basePriceCells = $derived(
|
||
|
|
$lastRequestSheetPrice[selectedCountry.toLowerCase()] ||
|
||
|
|
$lastRequestSheetPrice[selectedCountry] ||
|
||
|
|
{}
|
||
|
|
);
|
||
|
|
let basePricesLoadedCount = $derived(
|
||
|
|
mockProducts.filter((product) => getBasePrice(product) !== null).length
|
||
|
|
);
|
||
|
|
let basePriceLoading = $derived($sheetPriceLoading);
|
||
|
|
let filteredProducts = $derived(
|
||
|
|
currentSlot.products.filter((product) => {
|
||
|
|
const keyword = productCodeSearch.trim().toLowerCase();
|
||
|
|
if (!keyword) return true;
|
||
|
|
|
||
|
|
return product.product_code.toLowerCase().includes(keyword);
|
||
|
|
})
|
||
|
|
);
|
||
|
|
let changedCount = $derived(countChangedProducts(currentSlot, savedSnapshot[selectedSlot - 1]));
|
||
|
|
let hasHeaderChanges = $derived(
|
||
|
|
currentSlot.name !== savedSnapshot[selectedSlot - 1]?.name ||
|
||
|
|
currentSlot.description !== savedSnapshot[selectedSlot - 1]?.description
|
||
|
|
);
|
||
|
|
let hasChanges = $derived(changedCount > 0 || hasHeaderChanges);
|
||
|
|
|
||
|
|
onMount(() => {
|
||
|
|
referenceFromPage.set('priceslot');
|
||
|
|
|
||
|
|
if (selectedCountry) {
|
||
|
|
departmentStore.set(selectedCountry);
|
||
|
|
}
|
||
|
|
|
||
|
|
const userPerms = get(currentPerms).filter((x) => x.startsWith('document.write'));
|
||
|
|
enabledCountries = userPerms.map((x) => x.split('.')[2]);
|
||
|
|
});
|
||
|
|
|
||
|
|
function getBasePrice(product: PriceSlotProduct): number | null {
|
||
|
|
const cells = basePriceCells[product.product_code];
|
||
|
|
if (cells?.length > 0) {
|
||
|
|
const price = getPriceFromCells(selectedCountry.toLowerCase(), cells, 'cash_price');
|
||
|
|
const parsed = Number(price);
|
||
|
|
if (!Number.isNaN(parsed)) return parsed;
|
||
|
|
}
|
||
|
|
|
||
|
|
return product.price;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getProductNames(product: PriceSlotProduct) {
|
||
|
|
const [englishName = '', localName = ''] = product.name.split('|').map((name) => name.trim());
|
||
|
|
|
||
|
|
return {
|
||
|
|
english: englishName || product.name,
|
||
|
|
local: localName || englishName || product.name
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function calculateAdjustedPrice(basePrice: number | null): number | null {
|
||
|
|
if (basePrice === null) return null;
|
||
|
|
|
||
|
|
const value = Number(adjustmentValue);
|
||
|
|
if (Number.isNaN(value)) return basePrice;
|
||
|
|
|
||
|
|
const nextPrice =
|
||
|
|
adjustmentMode === 'increase_percent'
|
||
|
|
? basePrice * (1 + value / 100)
|
||
|
|
: adjustmentMode === 'increase_amount'
|
||
|
|
? basePrice + value
|
||
|
|
: adjustmentMode === 'decrease_percent'
|
||
|
|
? basePrice * (1 - value / 100)
|
||
|
|
: basePrice - value;
|
||
|
|
|
||
|
|
return Math.max(0, Math.round(nextPrice));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadBasePrices() {
|
||
|
|
const productCodes = mockProducts.map((product) => product.product_code);
|
||
|
|
if (productCodes.length === 0) return;
|
||
|
|
|
||
|
|
const socket = await waitForOpenSocket();
|
||
|
|
if (!socket) {
|
||
|
|
addNotification('WARN:WebSocket not connected. Using local base price sample.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
clearSheetPriceSentTypes();
|
||
|
|
const sent = requestSheetPrice(selectedCountry, productCodes);
|
||
|
|
if (!sent) {
|
||
|
|
addNotification('ERR:Failed to request base prices');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function applyCreateTemplate() {
|
||
|
|
const value = Number(adjustmentValue);
|
||
|
|
const formattedValue = Number.isNaN(value) ? 0 : value;
|
||
|
|
const isPercent =
|
||
|
|
adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent';
|
||
|
|
const isIncrease =
|
||
|
|
adjustmentMode === 'increase_percent' || adjustmentMode === 'increase_amount';
|
||
|
|
const action = isIncrease ? 'Increase' : 'Decrease';
|
||
|
|
const valueLabel = isPercent ? `${formattedValue}%` : `${formattedValue}`;
|
||
|
|
const nameSuffix = isPercent ? `${formattedValue}` : `Fixed${formattedValue}`;
|
||
|
|
|
||
|
|
createName = `Profile${action}${nameSuffix}`;
|
||
|
|
createDescription = `${isIncrease ? 'increase' : 'decrease'} price ${valueLabel}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function openCreateDialog() {
|
||
|
|
applyCreateTemplate();
|
||
|
|
createDialogOpen = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function createPriceSlotFromBase() {
|
||
|
|
const nextSlotNumber = Math.max(0, ...slots.map((slot) => slot.slot)) + 1;
|
||
|
|
|
||
|
|
const products = mockProducts.map((product) => ({
|
||
|
|
...product,
|
||
|
|
price: calculateAdjustedPrice(getBasePrice(product))
|
||
|
|
}));
|
||
|
|
|
||
|
|
const nextSlot: PriceSlot = {
|
||
|
|
slot: nextSlotNumber,
|
||
|
|
name: createName.trim() || `PriceSlot${nextSlotNumber}`,
|
||
|
|
description: createDescription.trim(),
|
||
|
|
products
|
||
|
|
};
|
||
|
|
|
||
|
|
slots = [...slots, nextSlot];
|
||
|
|
savedSnapshot = [...savedSnapshot, structuredClone(nextSlot)];
|
||
|
|
selectedSlot = nextSlotNumber;
|
||
|
|
createDialogOpen = false;
|
||
|
|
addNotification(`INFO:Created PriceSlot${nextSlotNumber} from base prices`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function countChangedProducts(current: PriceSlot, saved: PriceSlot | undefined): number {
|
||
|
|
if (!saved) return 0;
|
||
|
|
|
||
|
|
return current.products.filter((product) => {
|
||
|
|
const savedProduct = saved.products.find(
|
||
|
|
(item) => item.product_code === product.product_code
|
||
|
|
);
|
||
|
|
return savedProduct?.price !== product.price;
|
||
|
|
}).length;
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateSlotField(field: 'name' | 'description', value: string) {
|
||
|
|
slots = slots.map((slot) => (slot.slot === selectedSlot ? { ...slot, [field]: value } : slot));
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateProductPrice(productCode: string, value: string) {
|
||
|
|
const price = value === '' ? null : Number(value);
|
||
|
|
|
||
|
|
slots = slots.map((slot) => {
|
||
|
|
if (slot.slot !== selectedSlot) return slot;
|
||
|
|
|
||
|
|
return {
|
||
|
|
...slot,
|
||
|
|
products: slot.products.map((product) =>
|
||
|
|
product.product_code === productCode
|
||
|
|
? { ...product, price: Number.isNaN(price) ? product.price : price }
|
||
|
|
: product
|
||
|
|
)
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetSlot() {
|
||
|
|
const savedSlot = savedSnapshot[selectedSlot - 1];
|
||
|
|
if (!savedSlot) return;
|
||
|
|
|
||
|
|
slots = slots.map((slot) => (slot.slot === selectedSlot ? structuredClone(savedSlot) : slot));
|
||
|
|
addNotification(`INFO:Reset PriceSlot${selectedSlot}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveSlot() {
|
||
|
|
savedSnapshot = savedSnapshot.map((slot) =>
|
||
|
|
slot.slot === selectedSlot ? structuredClone(currentSlot) : slot
|
||
|
|
);
|
||
|
|
addNotification('WARN:PriceSlot backend is not ready. Changes are kept in this UI only.');
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadPriceSlots() {
|
||
|
|
loading = true;
|
||
|
|
setTimeout(() => {
|
||
|
|
loading = false;
|
||
|
|
addNotification('WARN:PriceSlot backend is not ready. Showing UI mock data.');
|
||
|
|
}, 250);
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<div class="min-h-screen bg-background">
|
||
|
|
<div class="w-full px-6 py-8 lg:px-8">
|
||
|
|
<div class="mb-7 flex flex-wrap items-start justify-between gap-5">
|
||
|
|
<div class="min-w-0">
|
||
|
|
<h1 class="text-4xl leading-tight font-bold tracking-normal">
|
||
|
|
PriceSlot [ {selectedCountry.toUpperCase()} ]
|
||
|
|
</h1>
|
||
|
|
<p class="mt-4 text-muted-foreground">
|
||
|
|
Edit sheet PriceSlot names, descriptions, and product prices.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div class="flex flex-wrap items-center gap-3">
|
||
|
|
{#if enabledCountries.length > 0}
|
||
|
|
<Select.Root
|
||
|
|
type="single"
|
||
|
|
value={selectedCountry}
|
||
|
|
onValueChange={(v) => {
|
||
|
|
if (v) {
|
||
|
|
selectedCountry = v;
|
||
|
|
goto(`/sheet/priceslot/${v}`);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Select.Trigger
|
||
|
|
class="h-11 w-40 rounded-lg border-border/80 bg-card/70 px-4 font-semibold"
|
||
|
|
>
|
||
|
|
{selectedCountry.toUpperCase()}
|
||
|
|
</Select.Trigger>
|
||
|
|
<Select.Content>
|
||
|
|
{#each enabledCountries as country}
|
||
|
|
<Select.Item value={country}>
|
||
|
|
{country.toUpperCase()}
|
||
|
|
</Select.Item>
|
||
|
|
{/each}
|
||
|
|
</Select.Content>
|
||
|
|
</Select.Root>
|
||
|
|
{/if}
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
class="h-11 rounded-lg"
|
||
|
|
onclick={loadPriceSlots}
|
||
|
|
disabled={loading}
|
||
|
|
>
|
||
|
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||
|
|
Refresh
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="mb-5 flex gap-2 overflow-x-auto border-b">
|
||
|
|
{#each slots as slot}
|
||
|
|
<button
|
||
|
|
class={[
|
||
|
|
'min-w-28 border-b-2 px-4 py-3 text-sm font-semibold transition-colors',
|
||
|
|
selectedSlot === slot.slot
|
||
|
|
? 'border-emerald-500 text-foreground'
|
||
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||
|
|
]}
|
||
|
|
onclick={() => (selectedSlot = slot.slot)}
|
||
|
|
>
|
||
|
|
PriceSlot{slot.slot}
|
||
|
|
</button>
|
||
|
|
{/each}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="mb-5 rounded-lg border border-border/80 bg-card p-4 shadow-sm">
|
||
|
|
<div
|
||
|
|
class="grid grid-cols-1 items-end gap-4 xl:grid-cols-[180px_minmax(220px,1fr)_minmax(280px,1.25fr)_auto]"
|
||
|
|
>
|
||
|
|
<div class="flex min-w-0 flex-col justify-end gap-2 pb-1">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<h2 class="text-xl font-bold tracking-normal">PriceSlot{selectedSlot}</h2>
|
||
|
|
<Badge variant={hasChanges ? 'default' : 'secondary'}>
|
||
|
|
{hasChanges ? `${changedCount} changes` : 'No changes'}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<!-- <p class="text-sm text-muted-foreground">Column K/L</p> -->
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-1.5">
|
||
|
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-name">Name</label>
|
||
|
|
<Input
|
||
|
|
id="priceslot-name"
|
||
|
|
class="h-10"
|
||
|
|
value={currentSlot.name}
|
||
|
|
oninput={(event) => updateSlotField('name', event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-1.5">
|
||
|
|
<label class="text-xs font-medium text-muted-foreground" for="priceslot-description">
|
||
|
|
Description
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="priceslot-description"
|
||
|
|
class="h-10"
|
||
|
|
value={currentSlot.description}
|
||
|
|
oninput={(event) => updateSlotField('description', event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex flex-wrap items-end justify-start gap-2 xl:justify-end">
|
||
|
|
<Button class="h-11 rounded-lg" onclick={openCreateDialog}>
|
||
|
|
<Calculator class="mr-2 h-4 w-4" />
|
||
|
|
Create PriceSlot
|
||
|
|
</Button>
|
||
|
|
<Button class="h-11 rounded-lg px-4" onclick={saveSlot} disabled={!hasChanges}>
|
||
|
|
<Save class="mr-2 h-4 w-4" />
|
||
|
|
Save Draft
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
class="h-11 rounded-lg px-4"
|
||
|
|
onclick={resetSlot}
|
||
|
|
disabled={!hasChanges}
|
||
|
|
>
|
||
|
|
<RotateCcw class="mr-2 h-4 w-4" />
|
||
|
|
Reset
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="mx-auto w-full max-w-6xl">
|
||
|
|
<div class="mb-3 flex flex-wrap items-end justify-between gap-3">
|
||
|
|
<div class="w-full max-w-sm space-y-2">
|
||
|
|
<label class="text-sm font-medium" for="product-code-search">Search ProductCode</label>
|
||
|
|
<div class="relative">
|
||
|
|
<Search
|
||
|
|
class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
id="product-code-search"
|
||
|
|
class="pl-9 font-mono"
|
||
|
|
placeholder="12-01-01-0001"
|
||
|
|
bind:value={productCodeSearch}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<p class="text-sm text-muted-foreground">
|
||
|
|
Showing {filteredProducts.length} of {currentSlot.products.length} products
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="w-full overflow-hidden rounded-lg border">
|
||
|
|
<Table.Root>
|
||
|
|
<Table.Header>
|
||
|
|
<Table.Row>
|
||
|
|
<Table.Head class="w-[190px]">ProductCode</Table.Head>
|
||
|
|
<Table.Head>ProductName [{selectedCountryLanguage}]</Table.Head>
|
||
|
|
<Table.Head>ProductNameEng</Table.Head>
|
||
|
|
<Table.Head class="w-[150px] text-right">Price</Table.Head>
|
||
|
|
</Table.Row>
|
||
|
|
</Table.Header>
|
||
|
|
<Table.Body>
|
||
|
|
{#each filteredProducts as product (product.product_code)}
|
||
|
|
{@const productNames = getProductNames(product)}
|
||
|
|
<Table.Row>
|
||
|
|
<Table.Cell class="font-mono text-sm font-semibold">
|
||
|
|
{product.product_code}
|
||
|
|
</Table.Cell>
|
||
|
|
<Table.Cell class="min-w-64 font-medium">{productNames.local}</Table.Cell>
|
||
|
|
<Table.Cell class="min-w-64 text-muted-foreground">{productNames.english}</Table.Cell>
|
||
|
|
<Table.Cell>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
step="1"
|
||
|
|
class="ml-auto w-32 text-right font-semibold"
|
||
|
|
value={product.price ?? ''}
|
||
|
|
oninput={(event) =>
|
||
|
|
updateProductPrice(product.product_code, event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</Table.Cell>
|
||
|
|
</Table.Row>
|
||
|
|
{/each}
|
||
|
|
{#if filteredProducts.length === 0}
|
||
|
|
<Table.Row>
|
||
|
|
<Table.Cell colspan={4} class="h-28 text-center text-muted-foreground">
|
||
|
|
No product code found.
|
||
|
|
</Table.Cell>
|
||
|
|
</Table.Row>
|
||
|
|
{/if}
|
||
|
|
</Table.Body>
|
||
|
|
</Table.Root>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog.Root bind:open={createDialogOpen}>
|
||
|
|
<Dialog.Content class="sm:max-w-2xl">
|
||
|
|
<Dialog.Header>
|
||
|
|
<Dialog.Title>Create PriceSlot</Dialog.Title>
|
||
|
|
<Dialog.Description>
|
||
|
|
Choose how to adjust base prices before creating a new PriceSlot.
|
||
|
|
</Dialog.Description>
|
||
|
|
</Dialog.Header>
|
||
|
|
|
||
|
|
<div class="space-y-5">
|
||
|
|
<!-- <div class="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
|
||
|
|
<div>
|
||
|
|
<p class="text-sm font-semibold">Base prices</p>
|
||
|
|
<p class="text-sm text-muted-foreground">
|
||
|
|
{basePriceLoading ? 'Loading prices from backend' : `${basePricesLoadedCount} products ready`}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" onclick={loadBasePrices} disabled={basePriceLoading}>
|
||
|
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||
|
|
Load Base
|
||
|
|
</Button>
|
||
|
|
</div> -->
|
||
|
|
|
||
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label class="text-sm font-medium" for="adjustment-mode">Adjustment Mode</label>
|
||
|
|
<Select.Root
|
||
|
|
type="single"
|
||
|
|
value={adjustmentMode}
|
||
|
|
onValueChange={(v) => {
|
||
|
|
if (v) adjustmentMode = v as AdjustmentMode;
|
||
|
|
applyCreateTemplate();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Select.Trigger id="adjustment-mode" class="h-10 rounded-lg">
|
||
|
|
{adjustmentModeLabels[adjustmentMode]}
|
||
|
|
</Select.Trigger>
|
||
|
|
<Select.Content>
|
||
|
|
<Select.Item value="increase_percent">Increase by Percentage (%)</Select.Item>
|
||
|
|
<Select.Item value="increase_amount">Increase by Fixed Amount</Select.Item>
|
||
|
|
<Select.Item value="decrease_percent">Decrease by Percentage (%)</Select.Item>
|
||
|
|
<Select.Item value="decrease_amount">Decrease by Fixed Amount</Select.Item>
|
||
|
|
</Select.Content>
|
||
|
|
</Select.Root>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label class="text-sm font-medium" for="adjustment-value">
|
||
|
|
Adjustment Value
|
||
|
|
{adjustmentMode === 'increase_percent' || adjustmentMode === 'decrease_percent'
|
||
|
|
? '(%)'
|
||
|
|
: ''}
|
||
|
|
</label>
|
||
|
|
<Input
|
||
|
|
id="adjustment-value"
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
step="1"
|
||
|
|
value={adjustmentValue}
|
||
|
|
oninput={(event) => {
|
||
|
|
adjustmentValue = Number(event.currentTarget.value);
|
||
|
|
applyCreateTemplate();
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label class="text-sm font-medium" for="create-name">Name</label>
|
||
|
|
<Input
|
||
|
|
id="create-name"
|
||
|
|
value={createName}
|
||
|
|
oninput={(event) => (createName = event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<label class="text-sm font-medium" for="create-description">Description</label>
|
||
|
|
<Input
|
||
|
|
id="create-description"
|
||
|
|
value={createDescription}
|
||
|
|
oninput={(event) => (createDescription = event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog.Footer>
|
||
|
|
<Button variant="outline" onclick={() => (createDialogOpen = false)}>Cancel</Button>
|
||
|
|
<Button onclick={createPriceSlotFromBase}>
|
||
|
|
<Calculator class="mr-2 h-4 w-4" />
|
||
|
|
Confirm Create
|
||
|
|
</Button>
|
||
|
|
</Dialog.Footer>
|
||
|
|
</Dialog.Content>
|
||
|
|
</Dialog.Root>
|