Supra_App/src/routes/(authed)/sheet/price/[country]/+page.svelte

281 lines
8.5 KiB
Svelte
Raw Normal View History

<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>