mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
552 lines
21 KiB
TypeScript
552 lines
21 KiB
TypeScript
import type {
|
|
Extra,
|
|
Filament,
|
|
FilamentPatch,
|
|
MultiColorDirection,
|
|
Spool,
|
|
SpoolPatch,
|
|
Vendor,
|
|
VendorPatch
|
|
} from '$lib/types';
|
|
import type { GroupField, GroupQuery, GroupSummary, Page, SpoolQuery } from './types';
|
|
import { getList, getJson, patchJson, postJson, putJson, deleteResource, HttpError } from './http';
|
|
import type { QueryParams } from './http';
|
|
import {
|
|
mapFilament,
|
|
mapGroup,
|
|
mapSpool,
|
|
mapVendor,
|
|
spoolPatchToApi,
|
|
filamentPatchToApi,
|
|
vendorPatchToApi,
|
|
colorFieldsToApi
|
|
} from './map';
|
|
import { inventory } from '$lib/stores/inventory.svelte';
|
|
import { isDateFilterProp, parseDateFilter, resolveDateFilter } from '$lib/library/dateFilter';
|
|
import { filamentLabel, type FilterOption } from '$lib/utils/library';
|
|
import type { EntityType } from './fields';
|
|
import { type ExternalFilament } from './external';
|
|
import { searchAll } from './search';
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
type Json = Record<string, any>;
|
|
|
|
// HTTP-backed data source against the real Spoolman API. It also populates the
|
|
// reactive cache with every entity it fetches, so detail views (which read the
|
|
// cache) have their data as soon as it's visible in the list.
|
|
|
|
export interface NewFilamentDraft {
|
|
name: string;
|
|
vendorName: string;
|
|
material: string;
|
|
density: number;
|
|
diameter: number;
|
|
weight?: number;
|
|
spoolWeight?: number;
|
|
/** One or more hex colors (with or without '#'); 2+ makes it multi-color. */
|
|
colors?: string[];
|
|
multiColorDirection?: MultiColorDirection;
|
|
nozzleTemp?: number;
|
|
bedTemp?: number;
|
|
price?: number;
|
|
articleNumber?: string;
|
|
comment?: string;
|
|
/** Custom-field values to carry over (used when duplicating a filament). */
|
|
extra?: Extra;
|
|
/** Custom-field values for the manufacturer, applied only if it is created here. */
|
|
vendorExtra?: Extra;
|
|
}
|
|
|
|
// Map a filter chip prop → API query param. Values are quoted for exact match.
|
|
const FILTER_PARAM: Record<string, string> = {
|
|
filament: 'filament.id',
|
|
material: 'filament.material',
|
|
vendor: 'filament.vendor.name',
|
|
direction: 'filament.multi_color_direction',
|
|
location: 'location',
|
|
lot: 'lot_nr'
|
|
};
|
|
|
|
// Filters whose values are passed through verbatim rather than double-quoted for
|
|
// exact string match. `filament` filters by numeric id, which the API parses as
|
|
// an int and would reject if quoted. `direction` is passed verbatim so its empty
|
|
// value (single-color = no direction) hits the backend's NULL-match branch, which
|
|
// quoting would turn into an exact match on the literal empty string instead.
|
|
const UNQUOTED_FILTERS = new Set(['filament', 'direction']);
|
|
|
|
// A filter prop is either one of the fixed categories above or an extra-field
|
|
// filter whose prop is already the query param the backend expects — `extra.<key>`
|
|
// for spool fields, `filament.extra.<key>` / `filament.vendor.extra.<key>` for a
|
|
// spool's filament or vendor.
|
|
function filterParam(prop: string): string | undefined {
|
|
return prop.includes('extra.') ? prop : FILTER_PARAM[prop];
|
|
}
|
|
|
|
function quote(v: string): string {
|
|
return `"${v}"`;
|
|
}
|
|
|
|
function applyFilters(params: QueryParams, filters: Record<string, string[]>) {
|
|
for (const [prop, values] of Object.entries(filters)) {
|
|
if (!values.length) continue;
|
|
// A date filter is a range rather than a set of values, and the API spells one
|
|
// as `<field>=<start>|<end>` with either end optional — the same grammar its
|
|
// datetime extra-field filters take. An empty value asks for "no timestamp at
|
|
// all", the way an empty value means "unset" for every other filter here.
|
|
//
|
|
// The bounds are resolved at request time, so a relative range like "the last
|
|
// 24 hours" is measured from now on every fetch rather than from whenever the
|
|
// chip was created.
|
|
if (isDateFilterProp(prop)) {
|
|
const filter = parseDateFilter(values[values.length - 1]);
|
|
if (!filter) continue;
|
|
const { after, before, unset } = resolveDateFilter(filter);
|
|
params[prop] = unset ? '' : `${after ?? ''}|${before ?? ''}`;
|
|
continue;
|
|
}
|
|
const key = filterParam(prop);
|
|
if (!key) continue;
|
|
const encode = UNQUOTED_FILTERS.has(prop) ? (v: string) => v : quote;
|
|
params[key] = values.map(encode).join(',');
|
|
}
|
|
}
|
|
|
|
function sortParam(sort: { field: string; dir: string }[]): string | undefined {
|
|
return sort.length ? sort.map((s) => `${s.field}:${s.dir}`).join(',') : undefined;
|
|
}
|
|
|
|
function scopeParams(params: QueryParams, scope: SpoolQuery['groupScope']) {
|
|
if (!scope) return;
|
|
// An empty key is the group of spools whose value is unset. Every string-valued
|
|
// field spells that as an empty filter, which the backend reads as "null or empty"
|
|
// (and, for an extra field, "no row for this key either").
|
|
const exact = (v: string) => (v === '' ? '' : quote(v));
|
|
switch (scope.field) {
|
|
case 'filament':
|
|
params['filament.id'] = scope.key;
|
|
break;
|
|
case 'vendor':
|
|
params['filament.vendor.id'] = scope.key === '' ? '-1' : scope.key;
|
|
break;
|
|
case 'material':
|
|
params['filament.material'] = exact(scope.key);
|
|
break;
|
|
case 'location':
|
|
params['location'] = exact(scope.key);
|
|
break;
|
|
default:
|
|
// `extra.<key>`, which is already the query param the backend expects.
|
|
params[scope.field] = exact(scope.key);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/** Treat a 404 as "no such entity" (undefined); re-throw anything else. */
|
|
function swallow404(e: unknown): null {
|
|
if (e instanceof HttpError && e.status === 404) return null;
|
|
throw e;
|
|
}
|
|
|
|
function cacheSpools(raw: unknown[]): Spool[] {
|
|
const spools = (raw as Json[]).map((s) => {
|
|
if (s.filament) {
|
|
inventory.upsertFilament(mapFilament(s.filament));
|
|
if (s.filament.vendor) inventory.upsertVendor(mapVendor(s.filament.vendor));
|
|
}
|
|
return mapSpool(s);
|
|
});
|
|
inventory.upsertSpools(spools);
|
|
return spools;
|
|
}
|
|
|
|
class HttpSpoolSource {
|
|
async listGroups(query: GroupQuery): Promise<Page<GroupSummary>> {
|
|
const params: QueryParams = {
|
|
group_by: query.field,
|
|
sort: sortParam(query.sort),
|
|
limit: query.limit,
|
|
offset: query.offset,
|
|
allow_archived: query.allowArchived ? 'true' : undefined,
|
|
include_empty: query.includeEmpty ? 'true' : undefined
|
|
};
|
|
applyFilters(params, query.filters);
|
|
|
|
const { items, total } = await getList('/spool/group', params, query.signal);
|
|
for (const g of items as Json[]) {
|
|
if (g.filament) {
|
|
inventory.upsertFilament(mapFilament(g.filament));
|
|
if (g.filament.vendor) inventory.upsertVendor(mapVendor(g.filament.vendor));
|
|
}
|
|
if (g.vendor) inventory.upsertVendor(mapVendor(g.vendor));
|
|
}
|
|
return { items: (items as Json[]).map(mapGroup), total };
|
|
}
|
|
|
|
async listSpools(query: SpoolQuery): Promise<Page<Spool>> {
|
|
const params: QueryParams = {
|
|
sort: sortParam(query.sort),
|
|
limit: query.limit,
|
|
offset: query.offset,
|
|
allow_archived: query.allowArchived ? 'true' : undefined
|
|
};
|
|
applyFilters(params, query.filters);
|
|
scopeParams(params, query.groupScope);
|
|
|
|
const { items, total } = await getList('/spool', params, query.signal);
|
|
return { items: cacheSpools(items), total };
|
|
}
|
|
|
|
/**
|
|
* Load a batch of filaments for the label picker/preview, newest first, and
|
|
* cache them. Filament catalogs are far smaller than spool inventories, so a
|
|
* single generous page covers virtually every library; the label picker also
|
|
* has a search box for anything past the cap.
|
|
*/
|
|
async listFilaments(limit = 1000, signal?: AbortSignal): Promise<Filament[]> {
|
|
const items = await getJson<Json[]>('/filament', { limit, sort: 'id:desc' }, signal);
|
|
const filaments = items.map((f) => {
|
|
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
|
return mapFilament(f);
|
|
});
|
|
inventory.upsertFilaments(filaments);
|
|
return filaments;
|
|
}
|
|
|
|
async searchFilaments(query: string, limit = 8): Promise<Filament[]> {
|
|
const q = query.trim();
|
|
if (!q) {
|
|
// No query yet: browse a slice of the catalog (sorted by name) so the
|
|
// picker has something to show as soon as it opens.
|
|
const params: QueryParams = { limit, sort: 'name:asc' };
|
|
const items = await getJson<Json[]>('/filament', params);
|
|
const filaments = items.map((f) => {
|
|
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
|
return mapFilament(f);
|
|
});
|
|
inventory.upsertFilaments(filaments);
|
|
return filaments;
|
|
}
|
|
// Reuse the cross-entity /search backend that powers the library search bar,
|
|
// so the picker matches on name, material, article number, comment, extra
|
|
// fields and colour — not just the filament name. We only want the filament
|
|
// hits here (results already upsert into the reactive cache).
|
|
const { filaments } = await searchAll(q, 20);
|
|
return filaments.map((m) => m.entity).slice(0, limit);
|
|
}
|
|
|
|
async searchExternalFilaments(query: string, limit = 8): Promise<ExternalFilament[]> {
|
|
// Filtering happens server-side (/external/filament/search) so the whole catalog
|
|
// — thousands of entries — never has to be downloaded to the client.
|
|
const q = query.trim();
|
|
if (!q) return [];
|
|
return getJson<ExternalFilament[]>('/external/filament/search', { query: q, limit });
|
|
}
|
|
|
|
/**
|
|
* Import a SpoolmanDB filament into the local catalog (creating its vendor if
|
|
* needed), de-duplicated by external_id. Returns the local filament.
|
|
*/
|
|
async importExternalFilament(ext: ExternalFilament): Promise<Filament> {
|
|
// Already imported?
|
|
const existing = await getJson<Json[]>('/filament', { external_id: ext.id });
|
|
if (existing.length) {
|
|
if (existing[0].vendor) inventory.upsertVendor(mapVendor(existing[0].vendor));
|
|
const f = mapFilament(existing[0]);
|
|
inventory.upsertFilament(f);
|
|
return f;
|
|
}
|
|
|
|
// Get-or-create the vendor (matched by external_id = manufacturer name).
|
|
let vendorId: number;
|
|
const vendors = await getJson<Json[]>('/vendor', { external_id: ext.manufacturer });
|
|
if (vendors.length) {
|
|
inventory.upsertVendor(mapVendor(vendors[0]));
|
|
vendorId = vendors[0].id;
|
|
} else {
|
|
const created = await postJson<Json>('/vendor', {
|
|
name: ext.manufacturer,
|
|
external_id: ext.manufacturer
|
|
});
|
|
inventory.upsertVendor(mapVendor(created));
|
|
vendorId = created.id;
|
|
}
|
|
|
|
const body: Json = {
|
|
name: ext.name,
|
|
material: ext.material,
|
|
vendor_id: vendorId,
|
|
density: ext.density,
|
|
diameter: ext.diameter,
|
|
weight: ext.weight,
|
|
spool_weight: ext.spool_weight ?? undefined,
|
|
settings_extruder_temp: ext.extruder_temp ?? undefined,
|
|
settings_bed_temp: ext.bed_temp ?? undefined,
|
|
external_id: ext.id
|
|
};
|
|
if (ext.color_hexes && ext.color_hexes.length) {
|
|
body.multi_color_hexes = ext.color_hexes.map((h) => h.replace(/^#/, '')).join(',');
|
|
if (ext.multi_color_direction) body.multi_color_direction = ext.multi_color_direction;
|
|
} else if (ext.color_hex) {
|
|
body.color_hex = ext.color_hex.replace(/^#/, '');
|
|
}
|
|
|
|
const created = await postJson<Json>('/filament', body);
|
|
const f = mapFilament(created);
|
|
inventory.upsertFilament(f);
|
|
return f;
|
|
}
|
|
|
|
/**
|
|
* Find a vendor by (case-insensitive) name or create it; returns its id.
|
|
*
|
|
* `extra` only applies to a vendor this call creates — matching an existing
|
|
* name links that record, it never edits it.
|
|
*/
|
|
async getOrCreateVendor(name: string, extra?: Extra): Promise<number | undefined> {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return undefined;
|
|
const all = await getJson<Json[]>('/vendor');
|
|
const match = all.find((v) => (v.name ?? '').toLowerCase() === trimmed.toLowerCase());
|
|
if (match) {
|
|
inventory.upsertVendor(mapVendor(match));
|
|
return match.id;
|
|
}
|
|
const created = await postJson<Json>('/vendor', {
|
|
name: trimmed,
|
|
extra: extra && Object.keys(extra).length ? extra : undefined
|
|
});
|
|
inventory.upsertVendor(mapVendor(created));
|
|
return created.id;
|
|
}
|
|
|
|
/** Create a brand-new filament (and its vendor if needed) from a draft. */
|
|
async createFilament(draft: NewFilamentDraft): Promise<Filament> {
|
|
const vendorId = await this.getOrCreateVendor(draft.vendorName, draft.vendorExtra);
|
|
const body: Json = {
|
|
name: draft.name || undefined,
|
|
material: draft.material || undefined,
|
|
vendor_id: vendorId,
|
|
density: draft.density,
|
|
diameter: draft.diameter,
|
|
weight: draft.weight || undefined,
|
|
spool_weight: draft.spoolWeight || undefined,
|
|
settings_extruder_temp: draft.nozzleTemp || undefined,
|
|
settings_bed_temp: draft.bedTemp || undefined,
|
|
price: draft.price || undefined,
|
|
article_number: draft.articleNumber || undefined,
|
|
comment: draft.comment || undefined,
|
|
extra: draft.extra && Object.keys(draft.extra).length ? draft.extra : undefined,
|
|
...colorFieldsToApi(draft.colors, draft.multiColorDirection)
|
|
};
|
|
const created = await postJson<Json>('/filament', body);
|
|
const f = mapFilament(created);
|
|
inventory.upsertFilament(f);
|
|
return f;
|
|
}
|
|
|
|
/**
|
|
* Every manufacturer, ordered by name and cached.
|
|
*
|
|
* Unlike filaments there is no `/vendor` search endpoint and no external
|
|
* manufacturer catalog to page through, but the list is small — a manufacturer
|
|
* covers a whole brand, so even a large library has a few dozen — so the
|
|
* pickers fetch it whole and filter it in the browser.
|
|
*/
|
|
async listVendors(signal?: AbortSignal): Promise<Vendor[]> {
|
|
const items = await getJson<Json[]>('/vendor', {}, signal);
|
|
const vendors = items.map(mapVendor);
|
|
inventory.upsertVendors(vendors);
|
|
return vendors.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async listFilamentsByVendor(vendorId: string): Promise<Filament[]> {
|
|
const items = await getJson<Json[]>('/filament', { 'vendor.id': vendorId });
|
|
const filaments = items.map((f) => {
|
|
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
|
return mapFilament(f);
|
|
});
|
|
inventory.upsertFilaments(filaments);
|
|
return filaments;
|
|
}
|
|
|
|
// --- single-entity fetches (deep links) ---------------------------------
|
|
// The reactive cache is normally filled by the list/search/live paths, so a
|
|
// deep link straight to an entity that isn't on the loaded page finds nothing.
|
|
// These fetch one entity by id and upsert it (plus its embedded relations) so
|
|
// the inspector resolves regardless of list state. A 404 (stale link, deleted
|
|
// entity) resolves to undefined rather than throwing.
|
|
|
|
async fetchSpool(id: number, signal?: AbortSignal): Promise<Spool | undefined> {
|
|
const s = await getJson<Json | null>(`/spool/${id}`, {}, signal).catch(swallow404);
|
|
return s ? cacheSpools([s])[0] : undefined;
|
|
}
|
|
async fetchFilament(id: string, signal?: AbortSignal): Promise<Filament | undefined> {
|
|
const f = await getJson<Json | null>(`/filament/${id}`, {}, signal).catch(swallow404);
|
|
if (!f) return undefined;
|
|
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
|
const filament = mapFilament(f);
|
|
inventory.upsertFilament(filament);
|
|
return filament;
|
|
}
|
|
async fetchVendor(id: string, signal?: AbortSignal): Promise<Vendor | undefined> {
|
|
const v = await getJson<Json | null>(`/vendor/${id}`, {}, signal).catch(swallow404);
|
|
if (!v) return undefined;
|
|
const vendor = mapVendor(v);
|
|
inventory.upsertVendor(vendor);
|
|
return vendor;
|
|
}
|
|
|
|
// --- writes -------------------------------------------------------------
|
|
|
|
async saveSpool(id: number, patch: SpoolPatch): Promise<void> {
|
|
const updated = await patchJson<Json>(`/spool/${id}`, spoolPatchToApi(patch));
|
|
inventory.upsertSpool(mapSpool(updated));
|
|
}
|
|
async setSpoolArchived(id: number, archived: boolean): Promise<void> {
|
|
await this.saveSpool(id, { archived });
|
|
}
|
|
/** Consume (or, if negative, add back) filament by length. */
|
|
async useSpoolLength(id: number, length: number): Promise<Spool> {
|
|
const updated = await putJson<Json>(`/spool/${id}/use`, { use_length: length });
|
|
const spool = mapSpool(updated);
|
|
inventory.upsertSpool(spool);
|
|
return spool;
|
|
}
|
|
/** Consume (or, if negative, add back) filament by weight. */
|
|
async useSpoolWeight(id: number, weight: number): Promise<Spool> {
|
|
const updated = await putJson<Json>(`/spool/${id}/use`, { use_weight: weight });
|
|
const spool = mapSpool(updated);
|
|
inventory.upsertSpool(spool);
|
|
return spool;
|
|
}
|
|
/** Consume filament based on the current gross (spool + filament) weight measurement. */
|
|
async measureSpool(id: number, weight: number): Promise<Spool> {
|
|
const updated = await putJson<Json>(`/spool/${id}/measure`, { weight });
|
|
const spool = mapSpool(updated);
|
|
inventory.upsertSpool(spool);
|
|
return spool;
|
|
}
|
|
async saveFilament(id: string, patch: FilamentPatch): Promise<void> {
|
|
const updated = await patchJson<Json>(`/filament/${id}`, filamentPatchToApi(patch));
|
|
// The response embeds the manufacturer, which this patch may just have
|
|
// changed — cache it too, so a filament re-filed under one the cache has
|
|
// never seen resolves to a name rather than to nothing.
|
|
if (updated.vendor) inventory.upsertVendor(mapVendor(updated.vendor));
|
|
inventory.upsertFilament(mapFilament(updated));
|
|
}
|
|
async saveVendor(id: string, patch: VendorPatch): Promise<void> {
|
|
const updated = await patchJson<Json>(`/vendor/${id}`, vendorPatchToApi(patch));
|
|
inventory.upsertVendor(mapVendor(updated));
|
|
}
|
|
async createSpool(body: Json): Promise<Spool> {
|
|
const created = await postJson<Json>('/spool', body);
|
|
return cacheSpools([created])[0];
|
|
}
|
|
|
|
// --- deletes ------------------------------------------------------------
|
|
// Each drops the entity from the cache itself rather than waiting for the
|
|
// websocket to echo it back, so the list and inspector update at once (and
|
|
// still do when live updates are unavailable).
|
|
|
|
/**
|
|
* How many spools reference this filament, archived ones included — the number
|
|
* that decides whether the API will let it be deleted. Asks for one row and
|
|
* reads X-Total-Count rather than paging the spools in.
|
|
*/
|
|
async countSpoolsOfFilament(id: string, signal?: AbortSignal): Promise<number> {
|
|
const { total } = await getList(
|
|
'/spool',
|
|
{ 'filament.id': id, allow_archived: 'true', limit: 1 },
|
|
signal
|
|
);
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Delete a spool. Nothing references a spool, so this always succeeds — it is
|
|
* also the only way to free a filament for deletion, since archiving is just a
|
|
* flag and the foreign key still holds.
|
|
*/
|
|
async deleteSpool(id: number): Promise<void> {
|
|
await deleteResource(`/spool/${id}`);
|
|
inventory.removeSpool(id);
|
|
}
|
|
|
|
/** Delete a filament. Rejects with a 403 HttpError if it still has spools. */
|
|
async deleteFilament(id: string): Promise<void> {
|
|
await deleteResource(`/filament/${id}`);
|
|
inventory.removeFilament(id);
|
|
}
|
|
|
|
/**
|
|
* Delete a manufacturer. Its filaments are NOT deleted — the server clears
|
|
* their vendor and leaves them in the catalog, which `removeVendor` mirrors.
|
|
*/
|
|
async deleteVendor(id: string): Promise<void> {
|
|
await deleteResource(`/vendor/${id}`);
|
|
inventory.removeVendor(id);
|
|
}
|
|
|
|
// --- filter option lists -----------------------------------------------
|
|
|
|
async materials(): Promise<string[]> {
|
|
return getJson<string[]>('/material');
|
|
}
|
|
/**
|
|
* Every filament as an option for the "filament" filter, so a spool listing can
|
|
* be narrowed to one specific filament (e.g. a given colour from a given
|
|
* manufacturer). Each option carries the filament's colours and material as
|
|
* well as its label, since the menu shows all three. Filaments are cached so
|
|
* the chosen chip can render its label from the reactive store.
|
|
*/
|
|
async filamentOptions(): Promise<FilterOption[]> {
|
|
const items = await getJson<Json[]>('/filament');
|
|
const filaments = items.map((f) => {
|
|
if (f.vendor) inventory.upsertVendor(mapVendor(f.vendor));
|
|
return mapFilament(f);
|
|
});
|
|
inventory.upsertFilaments(filaments);
|
|
// The label already carries the manufacturer, so the material is what's left
|
|
// to tell two same-named filaments apart; the colors are what the eye goes
|
|
// to first. Both are free here — this fetches whole filaments anyway.
|
|
return filaments
|
|
.map((f) => ({
|
|
value: f.id,
|
|
label: filamentLabel(f, inventory.vendorOf(f)),
|
|
meta: f.material,
|
|
colors: f.colors,
|
|
direction: f.multiColorDirection
|
|
}))
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
}
|
|
/** Distinct values currently stored for a scalar extra field (text/single-choice). */
|
|
async extraFieldValues(entity: EntityType, key: string): Promise<string[]> {
|
|
return getJson<string[]>(`/field/${entity}/${encodeURIComponent(key)}/values`);
|
|
}
|
|
async locations(): Promise<string[]> {
|
|
return getJson<string[]>('/location');
|
|
}
|
|
/**
|
|
* Replace one value of one spool field wherever it occurs, in a single request. Works for
|
|
* any field the spool owns, so the dashboard can rename a whole group without paging in its
|
|
* members — and without leaving out the ones it never loaded. Returns how many spools held
|
|
* the old value.
|
|
*/
|
|
async renameFieldValue(field: GroupField, value: string, newValue: string): Promise<number> {
|
|
const result = await patchJson<{ spools_updated: number }>(`/spool/field/${encodeURIComponent(field)}`, {
|
|
value,
|
|
new_value: newValue
|
|
});
|
|
return result.spools_updated;
|
|
}
|
|
async lotNumbers(): Promise<string[]> {
|
|
return getJson<string[]>('/lot-number');
|
|
}
|
|
async vendorNames(): Promise<string[]> {
|
|
const vendors = await this.listVendors();
|
|
return vendors.map((v) => v.name).filter(Boolean);
|
|
}
|
|
}
|
|
|
|
export const spoolSource = new HttpSpoolSource();
|