281 lines
8.5 KiB
Svelte
281 lines
8.5 KiB
Svelte
|
|
<script lang="ts">
|
||
|
|
import { onMount } from 'svelte';
|
||
|
|
import { page } from '$app/stores';
|
||
|
|
import { get } from 'svelte/store';
|
||
|
|
import { addNotification } from '$lib/core/stores/noti.js';
|
||
|
|
import { departmentStore } from '$lib/core/stores/departments.js';
|
||
|
|
import {
|
||
|
|
findHeaderIndex,
|
||
|
|
PRICE_HEADER_NAMES_BY_COUNTRY,
|
||
|
|
sheetPriceAllRows,
|
||
|
|
sheetPriceHeader,
|
||
|
|
sheetPriceLoading,
|
||
|
|
type GristCell
|
||
|
|
} from '$lib/core/stores/sheetStore.js';
|
||
|
|
import {
|
||
|
|
addSheetPrice,
|
||
|
|
requestAllSheetPrice,
|
||
|
|
updateSheetPrice
|
||
|
|
} from '$lib/core/services/sheetService.js';
|
||
|
|
import { waitForOpenSocket } from '$lib/core/stores/websocketStore.js';
|
||
|
|
import { referenceFromPage } from '$lib/core/stores/recipeStore.js';
|
||
|
|
import Button from '$lib/components/ui/button/button.svelte';
|
||
|
|
import Input from '$lib/components/ui/input/input.svelte';
|
||
|
|
import * as Table from '$lib/components/ui/table/index.js';
|
||
|
|
import Spinner from '$lib/components/ui/spinner/spinner.svelte';
|
||
|
|
import { Plus, RefreshCw, Save } from '@lucide/svelte/icons';
|
||
|
|
|
||
|
|
let selectedCountry = $state<string>($page.params.country || get(departmentStore) || '');
|
||
|
|
let search = $state('');
|
||
|
|
let saving = $state(false);
|
||
|
|
let newProductCode = $state('');
|
||
|
|
let newName = $state('');
|
||
|
|
let newPrice = $state('');
|
||
|
|
let priceEdits = $state<Record<string, string>>({});
|
||
|
|
let selectedCountryKey = $derived(selectedCountry.toLowerCase());
|
||
|
|
let loading = $derived($sheetPriceLoading);
|
||
|
|
let header = $derived($sheetPriceHeader[selectedCountryKey] ?? []);
|
||
|
|
let priceColumnIndex = $derived(getPriceColumnIndex());
|
||
|
|
let rowsByProductCode = $derived($sheetPriceAllRows[selectedCountryKey] ?? {});
|
||
|
|
let rows = $derived(
|
||
|
|
Object.values(rowsByProductCode)
|
||
|
|
.flat()
|
||
|
|
.sort((a, b) => a.row - b.row)
|
||
|
|
);
|
||
|
|
let filteredRows = $derived(
|
||
|
|
rows.filter((row) => {
|
||
|
|
const keyword = search.trim().toLowerCase();
|
||
|
|
if (!keyword) return true;
|
||
|
|
return row.cells.some((cell) =>
|
||
|
|
String(cell.value ?? '')
|
||
|
|
.toLowerCase()
|
||
|
|
.includes(keyword)
|
||
|
|
);
|
||
|
|
})
|
||
|
|
);
|
||
|
|
let visibleHeader = $derived(
|
||
|
|
Array.from(
|
||
|
|
{ length: Math.max(5, Math.min(12, header.length || 5)) },
|
||
|
|
(_, index) => header[index] || String.fromCharCode(65 + index)
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
onMount(() => {
|
||
|
|
referenceFromPage.set('price');
|
||
|
|
if (selectedCountry) departmentStore.set(selectedCountry);
|
||
|
|
void loadPrice();
|
||
|
|
});
|
||
|
|
|
||
|
|
async function loadPrice() {
|
||
|
|
if (!selectedCountry) return;
|
||
|
|
|
||
|
|
const socket = await waitForOpenSocket();
|
||
|
|
if (!socket) {
|
||
|
|
addNotification('ERR:WebSocket not connected');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sent = await requestAllSheetPrice(selectedCountry, true);
|
||
|
|
if (!sent) addNotification('ERR:Failed to request Price data');
|
||
|
|
}
|
||
|
|
|
||
|
|
function getPriceColumnIndex() {
|
||
|
|
const headerNames =
|
||
|
|
PRICE_HEADER_NAMES_BY_COUNTRY[selectedCountryKey] || PRICE_HEADER_NAMES_BY_COUNTRY.default;
|
||
|
|
const col = findHeaderIndex(header, headerNames.cash_price);
|
||
|
|
return col > 0 ? col : 5;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getCellValue(cells: GristCell[], column: number) {
|
||
|
|
return String(cells.find((cell) => cell.coord?.col === column)?.value ?? '');
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEditKey(row: { row: number }) {
|
||
|
|
return String(row.row);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getEditedPrice(row: { row: number; cells: GristCell[] }) {
|
||
|
|
const key = getEditKey(row);
|
||
|
|
return priceEdits[key] ?? getCellValue(row.cells, priceColumnIndex);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setEditedPrice(row: { row: number }, value: string) {
|
||
|
|
priceEdits = { ...priceEdits, [getEditKey(row)]: value };
|
||
|
|
}
|
||
|
|
|
||
|
|
function getChangedUpdates() {
|
||
|
|
return rows
|
||
|
|
.map((row) => {
|
||
|
|
const key = getEditKey(row);
|
||
|
|
if (!(key in priceEdits)) return null;
|
||
|
|
|
||
|
|
const original = getCellValue(row.cells, priceColumnIndex);
|
||
|
|
const value = priceEdits[key];
|
||
|
|
if (value === original) return null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
row_index: row.row,
|
||
|
|
cells: [{ value, coord: { row: row.row, col: priceColumnIndex } }]
|
||
|
|
};
|
||
|
|
})
|
||
|
|
.filter((update): update is NonNullable<typeof update> => update !== null);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function savePriceChanges() {
|
||
|
|
const updates = getChangedUpdates();
|
||
|
|
if (updates.length === 0) {
|
||
|
|
addNotification('INFO:No price changes to save');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
saving = true;
|
||
|
|
try {
|
||
|
|
const sent = await updateSheetPrice(selectedCountry, updates);
|
||
|
|
if (!sent) {
|
||
|
|
addNotification('ERR:Failed to send price updates');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
addNotification(`INFO:Updated ${updates.length} price row(s)`);
|
||
|
|
priceEdits = {};
|
||
|
|
await loadPrice();
|
||
|
|
} finally {
|
||
|
|
saving = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function addPriceRow() {
|
||
|
|
const productCode = String(newProductCode).trim();
|
||
|
|
const name = String(newName).trim();
|
||
|
|
const price = String(newPrice).trim();
|
||
|
|
|
||
|
|
if (!productCode) {
|
||
|
|
addNotification('WARN:ProductCode is required');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!price) {
|
||
|
|
addNotification('WARN:Price is required');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const cells = Array.from({ length: Math.max(header.length, priceColumnIndex) }, () => '');
|
||
|
|
cells[0] = productCode;
|
||
|
|
cells[1] = name;
|
||
|
|
cells[priceColumnIndex - 1] = price;
|
||
|
|
|
||
|
|
saving = true;
|
||
|
|
try {
|
||
|
|
const sent = await addSheetPrice(selectedCountry, [{ cells }]);
|
||
|
|
if (!sent) {
|
||
|
|
addNotification('ERR:Failed to add price row');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
addNotification(`INFO:Added price row ${productCode}`);
|
||
|
|
newProductCode = '';
|
||
|
|
newName = '';
|
||
|
|
newPrice = '';
|
||
|
|
await loadPrice();
|
||
|
|
} finally {
|
||
|
|
saving = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</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>
|
||
|
|
<h1 class="text-4xl leading-tight font-bold tracking-normal">
|
||
|
|
Price [ {selectedCountry.toUpperCase()} ]
|
||
|
|
</h1>
|
||
|
|
<p class="mt-4 text-muted-foreground">View main Price sheet data for this country.</p>
|
||
|
|
</div>
|
||
|
|
<div class="flex flex-wrap items-center gap-3">
|
||
|
|
<Button onclick={savePriceChanges} disabled={saving || getChangedUpdates().length === 0}>
|
||
|
|
{#if saving}
|
||
|
|
<Spinner class="mr-2 h-4 w-4" />
|
||
|
|
Saving
|
||
|
|
{:else}
|
||
|
|
<Save class="mr-2 h-4 w-4" />
|
||
|
|
Save Changes ({getChangedUpdates().length})
|
||
|
|
{/if}
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" onclick={loadPrice} disabled={loading || saving}>
|
||
|
|
{#if loading}
|
||
|
|
<Spinner class="mr-2 h-4 w-4" />
|
||
|
|
Loading
|
||
|
|
{:else}
|
||
|
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||
|
|
Refresh
|
||
|
|
{/if}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
class="mb-5 grid gap-3 rounded-xl border bg-card/60 p-4 lg:grid-cols-[1fr_180px_180px_140px_auto]"
|
||
|
|
>
|
||
|
|
<Input placeholder="Search product code, name, or price..." bind:value={search} />
|
||
|
|
<Input placeholder="New ProductCode" bind:value={newProductCode} class="font-mono" />
|
||
|
|
<Input placeholder="Name" bind:value={newName} />
|
||
|
|
<Input placeholder="Price" bind:value={newPrice} type="number" min="0" step="0.01" />
|
||
|
|
<Button variant="outline" onclick={addPriceRow} disabled={saving}>
|
||
|
|
<Plus class="mr-2 h-4 w-4" />
|
||
|
|
Add Row
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="rounded-xl border bg-card/60">
|
||
|
|
{#if loading && rows.length === 0}
|
||
|
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||
|
|
<Spinner class="mr-3 h-6 w-6" />
|
||
|
|
Loading Price data...
|
||
|
|
</div>
|
||
|
|
{:else if rows.length === 0}
|
||
|
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||
|
|
No Price data loaded. Click Refresh to load data.
|
||
|
|
</div>
|
||
|
|
{:else if filteredRows.length === 0}
|
||
|
|
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||
|
|
No rows match your search.
|
||
|
|
</div>
|
||
|
|
{:else}
|
||
|
|
<div class="max-h-[calc(100vh-220px)] overflow-auto">
|
||
|
|
<Table.Root>
|
||
|
|
<Table.Header class="sticky top-0 z-10 bg-card">
|
||
|
|
<Table.Row>
|
||
|
|
<Table.Head class="w-20">Row</Table.Head>
|
||
|
|
{#each visibleHeader as column}
|
||
|
|
<Table.Head>{column}</Table.Head>
|
||
|
|
{/each}
|
||
|
|
</Table.Row>
|
||
|
|
</Table.Header>
|
||
|
|
<Table.Body>
|
||
|
|
{#each filteredRows as row, index (`${row.row}-${row.cells[0]?.value ?? ''}-${index}`)}
|
||
|
|
<Table.Row>
|
||
|
|
<Table.Cell class="font-mono text-xs text-muted-foreground">{row.row}</Table.Cell>
|
||
|
|
{#each visibleHeader as _, index}
|
||
|
|
<Table.Cell class={index === 0 ? 'font-mono text-sm' : 'text-sm'}>
|
||
|
|
{#if index + 1 === priceColumnIndex}
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
step="0.01"
|
||
|
|
class="h-8 w-28 text-right font-semibold"
|
||
|
|
value={getEditedPrice(row)}
|
||
|
|
oninput={(event) => setEditedPrice(row, event.currentTarget.value)}
|
||
|
|
/>
|
||
|
|
{:else}
|
||
|
|
{row.cells.find((cell) => cell.coord?.col === index + 1)?.value ?? ''}
|
||
|
|
{/if}
|
||
|
|
</Table.Cell>
|
||
|
|
{/each}
|
||
|
|
</Table.Row>
|
||
|
|
{/each}
|
||
|
|
</Table.Body>
|
||
|
|
</Table.Root>
|
||
|
|
</div>
|
||
|
|
{/if}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|