Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import { render } from 'preact';
- import { useState, useEffect, useRef } from 'preact/hooks';
- interface ProductVariant {
- id: string;
- sku: string;
- barcode: string;
- title: string;
- product: {
- title: string;
- };
- inventoryItem: {
- id: string;
- };
- }
- interface ScannedItem {
- barcode: string;
- productTitle: string;
- variantTitle: string;
- sku: string;
- quantity: number;
- variantId?: string;
- }
- interface CollectionVariant {
- barcode: string;
- productTitle: string;
- variantTitle: string;
- sku: string;
- inventoryQuantity: number;
- variantId: string;
- }
- interface Collection {
- id: string;
- title: string;
- }
- interface ComparisonItem {
- barcode: string;
- productTitle: string;
- variantTitle: string;
- sku: string;
- expectedQuantity: number | null;
- scannedQuantity: number;
- difference: number | null;
- variantId: string | null;
- selected: boolean;
- }
- interface StockTakeSession {
- id: string;
- handle: string;
- name: string;
- createdDate: string;
- scannedItemsData: ScannedItem[];
- selectedCollectionId: string;
- lastUpdated: string;
- }
- function Extension() {
- const [barcodeInput, setBarcodeInput] = useState<string>('');
- const [scannedItems, setScannedItems] = useState<ScannedItem[]>([]);
- const [collections, setCollections] = useState<Collection[]>([]);
- const [selectedCollectionId, setSelectedCollectionId] = useState<string>('');
- const [collectionVariants, setCollectionVariants] = useState<CollectionVariant[]>([]);
- const [loadingCollections, setLoadingCollections] = useState<boolean>(false);
- const [loadingCollection, setLoadingCollection] = useState<boolean>(false);
- const [scanning, setScanning] = useState<boolean>(false);
- const [error, setError] = useState<string>('');
- const [successMessage, setSuccessMessage] = useState<string>('');
- const [showOnlyDiscrepancies, setShowOnlyDiscrepancies] = useState<boolean>(false);
- const [sessions, setSessions] = useState<StockTakeSession[]>([]);
- const [currentSessionId, setCurrentSessionId] = useState<string>('');
- const [loadingSessions, setLoadingSessions] = useState<boolean>(false);
- const [newSessionName, setNewSessionName] = useState<string>('');
- const [savingSession, setSavingSession] = useState<boolean>(false);
- const [definitionReady, setDefinitionReady] = useState<boolean>(false);
- const [editedQuantities, setEditedQuantities] = useState<Map<string, number>>(new Map());
- const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
- const [applyingInventory, setApplyingInventory] = useState<boolean>(false);
- const [deletingSession, setDeletingSession] = useState<boolean>(false);
- const newSessionModalRef = useRef<any>(null);
- const confirmApplyModalRef = useRef<any>(null);
- const confirmDeleteModalRef = useRef<any>(null);
- const debounceTimerRef = useRef<number | null>(null);
- const barcodeValueRef = useRef<string>('');
- const currentSessionIdRef = useRef<string>('');
- const scannedItemsRef = useRef<ScannedItem[]>([]);
- const focusBarcodeInput = () => {
- setTimeout(() => {
- try {
- const input = document.getElementById('barcode-input') as HTMLInputElement;
- if (input && typeof input.focus === 'function') {
- input.focus();
- }
- } catch (err) {
- // Focus failed
- }
- }, 100);
- };
- useEffect(() => {
- initializeApp();
- }, []);
- useEffect(() => {
- currentSessionIdRef.current = currentSessionId;
- }, [currentSessionId]);
- useEffect(() => {
- scannedItemsRef.current = scannedItems;
- }, [scannedItems]);
- useEffect(() => {
- if (currentSessionId && (scannedItems.length > 0 || selectedCollectionId)) {
- saveCurrentSession();
- }
- }, [scannedItems, selectedCollectionId]);
- useEffect(() => {
- const handleKeyPress = (e: KeyboardEvent) => {
- const target = e.target as HTMLElement;
- if (target && target.id === 'barcode-input') {
- if (e.key === 'Enter' || e.key === 'Tab') {
- e.preventDefault();
- processBarcodeScann();
- }
- }
- };
- document.addEventListener('keydown', handleKeyPress);
- return () => document.removeEventListener('keydown', handleKeyPress);
- }, []);
- useEffect(() => {
- return () => {
- if (debounceTimerRef.current) {
- clearTimeout(debounceTimerRef.current);
- }
- };
- }, []);
- const initializeApp = async () => {
- await ensureMetaobjectDefinition();
- await fetchSessions();
- await fetchCollections();
- };
- const ensureMetaobjectDefinition = async () => {
- try {
- const checkQuery = `query {
- metaobjectDefinitionByType(type: \"sidekick_stock_take\") {
- id
- }
- }`;
- const { data, errors } = await shopify.query(checkQuery);
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- setDefinitionReady(false);
- return;
- }
- if (data?.metaobjectDefinitionByType?.id) {
- setDefinitionReady(true);
- return;
- }
- const createMutation = `mutation CreateMetaobjectDefinition($definition: MetaobjectDefinitionCreateInput!) {
- metaobjectDefinitionCreate(definition: $definition) {
- metaobjectDefinition {
- id
- type
- }
- userErrors {
- field
- message
- code
- }
- }
- }`;
- const { data: createData, errors: createErrors } = await shopify.query(createMutation, {
- variables: {
- definition: {
- name: 'Stock Take Session',
- type: 'sidekick_stock_take',
- fieldDefinitions: [
- {
- name: 'Session Name',
- key: 'name',
- type: 'single_line_text_field',
- required: true,
- },
- {
- name: 'Created Date',
- key: 'created_date',
- type: 'date_time',
- required: true,
- },
- {
- name: 'Scanned Items Data',
- key: 'scanned_items_data',
- type: 'json',
- required: false,
- },
- {
- name: 'Selected Collection',
- key: 'selected_collection_id',
- type: 'collection_reference',
- required: false,
- },
- {
- name: 'Last Updated',
- key: 'last_updated',
- type: 'date_time',
- required: false,
- },
- ],
- },
- },
- });
- if (createErrors && createErrors.length > 0) {
- const errorMessage = createErrors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- return;
- }
- if (createData?.metaobjectDefinitionCreate?.userErrors?.length > 0) {
- const userErrorMessage = createData.metaobjectDefinitionCreate.userErrors
- .map((e: any) => (e.field ? `${e.field}: ${e.message}` : e.message))
- .join(', ');
- setError(userErrorMessage);
- setDefinitionReady(false);
- return;
- }
- if (createData?.metaobjectDefinitionCreate?.metaobjectDefinition?.id) {
- setDefinitionReady(true);
- }
- } catch (err: any) {
- setError(err.message || 'Failed to initialize metaobject definition');
- setDefinitionReady(false);
- }
- };
- const fetchSessions = async () => {
- setLoadingSessions(true);
- try {
- const sessionsQuery = `query GetSessions($first: Int!, $type: String!) {
- metaobjects(first: $first, type: $type) {
- edges {
- node {
- id
- handle
- name: field(key: \"name\") { jsonValue }
- createdDate: field(key: \"created_date\") { jsonValue }
- scannedItemsData: field(key: \"scanned_items_data\") { jsonValue }
- selectedCollectionId: field(key: \"selected_collection_id\") { jsonValue }
- lastUpdated: field(key: \"last_updated\") { jsonValue }
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }`;
- const { data, errors } = await shopify.query(sessionsQuery, {
- variables: {
- first: 250,
- type: 'sidekick_stock_take',
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- return;
- }
- if (data?.metaobjects) {
- const fetchedSessions = data.metaobjects.edges.map((edge: any) => ({
- id: edge.node.id,
- handle: edge.node.handle,
- name: edge.node.name?.jsonValue || '',
- createdDate: edge.node.createdDate?.jsonValue || '',
- scannedItemsData: edge.node.scannedItemsData?.jsonValue || [],
- selectedCollectionId: edge.node.selectedCollectionId?.jsonValue || '',
- lastUpdated: edge.node.lastUpdated?.jsonValue || '',
- }));
- setSessions(fetchedSessions);
- }
- } catch (err: any) {
- setError(err.message || 'Failed to load sessions');
- } finally {
- setLoadingSessions(false);
- }
- };
- const createNewSession = async () => {
- const sessionName = newSessionName.trim();
- if (!sessionName) {
- setError('Session name is required.');
- return;
- }
- if (!definitionReady) {
- setError('Metaobject definition is not ready. Please wait and try again.');
- return;
- }
- setSavingSession(true);
- setError('');
- try {
- const timestamp = new Date().toISOString();
- const handle = `session-${Date.now()}`;
- const upsertMutation = `mutation UpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
- metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
- metaobject {
- id
- handle
- }
- userErrors {
- field
- message
- code
- }
- }
- }`;
- const { data, errors } = await shopify.query(upsertMutation, {
- variables: {
- handle: {
- type: 'sidekick_stock_take',
- handle: handle,
- },
- metaobject: {
- fields: [
- { key: 'name', value: sessionName },
- { key: 'created_date', value: timestamp },
- { key: 'scanned_items_data', value: JSON.stringify([]) },
- { key: 'last_updated', value: timestamp },
- ],
- },
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- return;
- }
- if (data?.metaobjectUpsert?.userErrors?.length > 0) {
- const userErrorMessage = data.metaobjectUpsert.userErrors
- .map((e: any) => (e.field ? `${e.field}: ${e.message}` : e.message))
- .join(', ');
- setError(userErrorMessage);
- return;
- }
- setNewSessionName('');
- newSessionModalRef.current?.hideOverlay();
- await fetchSessions();
- if (data?.metaobjectUpsert?.metaobject?.id) {
- setCurrentSessionId(data.metaobjectUpsert.metaobject.id);
- setScannedItems([]);
- setSelectedCollectionId('');
- }
- } catch (err: any) {
- setError(err.message || 'Failed to create session');
- } finally {
- setSavingSession(false);
- }
- };
- const deleteSession = async () => {
- if (!currentSessionId) return;
- setDeletingSession(true);
- setError('');
- try {
- const deleteMutation = `mutation DeleteMetaobject($id: ID!) {
- metaobjectDelete(id: $id) {
- deletedId
- userErrors {
- field
- message
- code
- }
- }
- }`;
- const { data, errors } = await shopify.query(deleteMutation, {
- variables: {
- id: currentSessionId,
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- return;
- }
- if (data?.metaobjectDelete?.userErrors?.length > 0) {
- const userErrorMessage = data.metaobjectDelete.userErrors
- .map((e: any) => (e.field ? `${e.field}: ${e.message}` : e.message))
- .join(', ');
- setError(userErrorMessage);
- return;
- }
- confirmDeleteModalRef.current?.hideOverlay();
- setCurrentSessionId('');
- setScannedItems([]);
- setSelectedCollectionId('');
- setCollectionVariants([]);
- await fetchSessions();
- setSuccessMessage('Session deleted successfully');
- setTimeout(() => setSuccessMessage(''), 3000);
- } catch (err: any) {
- setError(err.message || 'Failed to delete session');
- } finally {
- setDeletingSession(false);
- }
- };
- const saveCurrentSession = async () => {
- if (!currentSessionId) return;
- const currentSession = sessions.find((s) => s.id === currentSessionId);
- if (!currentSession) return;
- try {
- const timestamp = new Date().toISOString();
- const upsertMutation = `mutation UpsertMetaobject($handle: MetaobjectHandleInput!, $metaobject: MetaobjectUpsertInput!) {
- metaobjectUpsert(handle: $handle, metaobject: $metaobject) {
- metaobject {
- id
- }
- userErrors {
- field
- message
- code
- }
- }
- }`;
- const fields: any[] = [
- { key: 'name', value: currentSession.name },
- { key: 'created_date', value: currentSession.createdDate },
- { key: 'scanned_items_data', value: JSON.stringify(scannedItems) },
- { key: 'last_updated', value: timestamp },
- ];
- if (selectedCollectionId) {
- fields.push({ key: 'selected_collection_id', value: selectedCollectionId });
- }
- const { data, errors } = await shopify.query(upsertMutation, {
- variables: {
- handle: {
- type: 'sidekick_stock_take',
- handle: currentSession.handle,
- },
- metaobject: {
- fields: fields,
- },
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- }
- if (data?.metaobjectUpsert?.userErrors?.length > 0) {
- const userErrorMessage = data.metaobjectUpsert.userErrors
- .map((e: any) => (e.field ? `${e.field}: ${e.message}` : e.message))
- .join(', ');
- setError(userErrorMessage);
- }
- } catch (err: any) {
- setError(err.message || 'Failed to save session');
- }
- };
- const loadSession = (sessionId: string) => {
- const session = sessions.find((s) => s.id === sessionId);
- if (!session) return;
- setCurrentSessionId(sessionId);
- setScannedItems(session.scannedItemsData || []);
- setSelectedCollectionId(session.selectedCollectionId || '');
- if (session.selectedCollectionId) {
- fetchCollectionInventory(session.selectedCollectionId);
- }
- };
- const playErrorBeep = () => {
- try {
- const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
- const oscillator = audioContext.createOscillator();
- const gainNode = audioContext.createGain();
- oscillator.connect(gainNode);
- gainNode.connect(audioContext.destination);
- oscillator.frequency.value = 400;
- oscillator.type = 'sine';
- gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
- gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
- oscillator.start(audioContext.currentTime);
- oscillator.stop(audioContext.currentTime + 0.2);
- } catch (err) {
- // Audio API not available
- }
- };
- const fetchCollections = async () => {
- setLoadingCollections(true);
- setError('');
- const allCollections: Collection[] = [];
- let hasNextPage: boolean = true;
- let cursor: string | null = null;
- try {
- while (hasNextPage) {
- const collectionsQuery = `query GetCollections($first: Int!, $after: String) {
- collections(first: $first, after: $after) {
- edges {
- node {
- id
- title
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }`;
- const { data, errors } = await shopify.query(collectionsQuery, {
- variables: {
- first: 250,
- after: cursor,
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- break;
- }
- if (data?.collections) {
- const fetchedCollections = data.collections.edges.map((edge: any) => edge.node);
- allCollections.push(...fetchedCollections);
- hasNextPage = data.collections.pageInfo.hasNextPage;
- cursor = data.collections.pageInfo.endCursor;
- } else {
- break;
- }
- }
- setCollections(allCollections);
- if (allCollections.length === 0) {
- setError('No collections found in your store.');
- }
- } catch (err: any) {
- setError(err.message || 'Failed to load collections');
- } finally {
- setLoadingCollections(false);
- }
- };
- const fetchCollectionInventory = async (collectionId: string) => {
- setLoadingCollection(true);
- setError('');
- setCollectionVariants([]);
- const allVariants: CollectionVariant[] = [];
- let hasNextProductPage: boolean = true;
- let productCursor: string | null = null;
- try {
- while (hasNextProductPage) {
- const collectionQuery = `query GetCollection($id: ID!, $first: Int!, $after: String) {
- collection(id: $id) {
- id
- title
- products(first: $first, after: $after) {
- edges {
- node {
- id
- title
- variants(first: 100) {
- edges {
- node {
- id
- title
- sku
- barcode
- inventoryQuantity
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }`;
- const { data, errors } = await shopify.query(collectionQuery, {
- variables: {
- id: collectionId,
- first: 25,
- after: productCursor,
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- break;
- }
- if (data?.collection?.products) {
- const products = data.collection.products.edges.map((edge: any) => edge.node);
- for (const product of products) {
- let hasNextVariantPage: boolean = product.variants.pageInfo.hasNextPage;
- let variantCursor: string | null = product.variants.pageInfo.endCursor;
- const variants = product.variants.edges.map((edge: any) => edge.node);
- for (const variant of variants) {
- if (variant.barcode) {
- allVariants.push({
- barcode: variant.barcode,
- productTitle: product.title,
- variantTitle: variant.title,
- sku: variant.sku || '',
- inventoryQuantity: variant.inventoryQuantity || 0,
- variantId: variant.id,
- });
- }
- }
- while (hasNextVariantPage) {
- const variantQuery = `query GetProductVariants($productId: ID!, $first: Int!, $after: String) {
- product(id: $productId) {
- variants(first: $first, after: $after) {
- edges {
- node {
- id
- title
- sku
- barcode
- inventoryQuantity
- }
- }
- pageInfo {
- hasNextPage
- endCursor
- }
- }
- }
- }`;
- const variantResult = await shopify.query(variantQuery, {
- variables: {
- productId: product.id,
- first: 100,
- after: variantCursor,
- },
- });
- if (variantResult.errors && variantResult.errors.length > 0) {
- break;
- }
- if (variantResult.data?.product?.variants) {
- const moreVariants = variantResult.data.product.variants.edges.map(
- (edge: any) => edge.node,
- );
- for (const variant of moreVariants) {
- if (variant.barcode) {
- allVariants.push({
- barcode: variant.barcode,
- productTitle: product.title,
- variantTitle: variant.title,
- sku: variant.sku || '',
- inventoryQuantity: variant.inventoryQuantity || 0,
- variantId: variant.id,
- });
- }
- }
- hasNextVariantPage = variantResult.data.product.variants.pageInfo.hasNextPage;
- variantCursor = variantResult.data.product.variants.pageInfo.endCursor;
- } else {
- break;
- }
- }
- }
- hasNextProductPage = data.collection.products.pageInfo.hasNextPage;
- productCursor = data.collection.products.pageInfo.endCursor;
- } else {
- break;
- }
- }
- setCollectionVariants(allVariants);
- if (allVariants.length === 0) {
- setError('This collection has no products.');
- }
- } catch (err: any) {
- setError(err.message || 'Failed to load collection inventory');
- } finally {
- setLoadingCollection(false);
- }
- };
- const handleBarcodeInput = (e: Event) => {
- const value = (e.currentTarget as HTMLInputElement).value;
- if (debounceTimerRef.current) {
- clearTimeout(debounceTimerRef.current);
- }
- if (value.endsWith('\r') || value.endsWith('\n')) {
- const cleanValue = value.replace(/[\r\n]+$/, '');
- barcodeValueRef.current = cleanValue;
- setBarcodeInput(cleanValue);
- processBarcodeScann();
- return;
- }
- barcodeValueRef.current = value;
- setBarcodeInput(value);
- if (value.trim().length > 0) {
- debounceTimerRef.current = setTimeout(() => {
- processBarcodeScann();
- }, 300);
- }
- };
- const processBarcodeScann = async () => {
- if (debounceTimerRef.current) {
- clearTimeout(debounceTimerRef.current);
- debounceTimerRef.current = null;
- }
- const barcode = barcodeValueRef.current.trim();
- if (!barcode || !currentSessionIdRef.current) {
- return;
- }
- setScanning(true);
- setError('');
- setSuccessMessage('');
- try {
- const variantQuery = `query GetVariantByBarcode($query: String!, $first: Int!) {
- productVariants(first: $first, query: $query) {
- edges {
- node {
- id
- sku
- barcode
- title
- product {
- title
- }
- inventoryItem {
- id
- }
- }
- }
- }
- }`;
- const { data, errors } = await shopify.query(variantQuery, {
- variables: {
- query: `barcode:${barcode}`,
- first: 1,
- },
- });
- if (errors && errors.length > 0) {
- const errorMessage = errors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- barcodeValueRef.current = '';
- setBarcodeInput('');
- focusBarcodeInput();
- return;
- }
- if (data?.productVariants?.edges?.length > 0) {
- const variant: ProductVariant = data.productVariants.edges[0].node;
- const existingIndex = scannedItemsRef.current.findIndex(
- (item) => item.barcode === variant.barcode,
- );
- if (existingIndex >= 0) {
- const updatedItems = [...scannedItemsRef.current];
- updatedItems[existingIndex].quantity += 1;
- setScannedItems(updatedItems);
- setSuccessMessage(`Quantity updated: ${variant.product.title} - ${variant.title}`);
- } else {
- setScannedItems([
- ...scannedItemsRef.current,
- {
- barcode: variant.barcode,
- productTitle: variant.product.title,
- variantTitle: variant.title,
- sku: variant.sku || '',
- quantity: 1,
- variantId: variant.id,
- },
- ]);
- setSuccessMessage(`Item added: ${variant.product.title} - ${variant.title}`);
- }
- setTimeout(() => setSuccessMessage(''), 3000);
- barcodeValueRef.current = '';
- setBarcodeInput('');
- focusBarcodeInput();
- } else {
- playErrorBeep();
- setError('Barcode not found. Please check the barcode and try again.');
- barcodeValueRef.current = '';
- setBarcodeInput('');
- focusBarcodeInput();
- }
- } catch (err: any) {
- setError(err.message || 'Failed to scan barcode');
- barcodeValueRef.current = '';
- setBarcodeInput('');
- focusBarcodeInput();
- } finally {
- setScanning(false);
- }
- };
- const handleClearScans = () => {
- setScannedItems([]);
- setError('');
- };
- const handleSessionChange = (e: Event) => {
- const target = e.currentTarget as HTMLSelectElement;
- const sessionId = target.value;
- if (sessionId) {
- loadSession(sessionId);
- } else {
- setCurrentSessionId('');
- setScannedItems([]);
- setSelectedCollectionId('');
- setCollectionVariants([]);
- }
- };
- const handleCollectionChange = (e: Event) => {
- const target = e.currentTarget as HTMLSelectElement;
- const collectionId = target.value;
- setSelectedCollectionId(collectionId);
- if (collectionId) {
- fetchCollectionInventory(collectionId);
- } else {
- setCollectionVariants([]);
- }
- };
- const getComparisonData = (): ComparisonItem[] => {
- const comparisonMap = new Map<string, ComparisonItem>();
- collectionVariants.forEach((variant) => {
- const editedQty = editedQuantities.get(variant.barcode);
- const scannedQty = editedQty !== undefined ? editedQty : 0;
- comparisonMap.set(variant.barcode, {
- barcode: variant.barcode,
- productTitle: variant.productTitle,
- variantTitle: variant.variantTitle,
- sku: variant.sku,
- expectedQuantity: variant.inventoryQuantity,
- scannedQuantity: scannedQty,
- difference: null,
- variantId: variant.variantId,
- selected: selectedItems.has(variant.barcode),
- });
- });
- scannedItems.forEach((item) => {
- const editedQty = editedQuantities.get(item.barcode);
- const scannedQty = editedQty !== undefined ? editedQty : item.quantity;
- if (comparisonMap.has(item.barcode)) {
- const existing = comparisonMap.get(item.barcode)!;
- existing.scannedQuantity = scannedQty;
- existing.difference = scannedQty - (existing.expectedQuantity || 0);
- } else {
- comparisonMap.set(item.barcode, {
- barcode: item.barcode,
- productTitle: item.productTitle,
- variantTitle: item.variantTitle,
- sku: item.sku,
- expectedQuantity: null,
- scannedQuantity: scannedQty,
- difference: null,
- variantId: item.variantId || null,
- selected: selectedItems.has(item.barcode),
- });
- }
- });
- comparisonMap.forEach((item) => {
- if (item.expectedQuantity !== null && item.difference === null) {
- item.difference = item.scannedQuantity - item.expectedQuantity;
- }
- });
- return Array.from(comparisonMap.values());
- };
- const handleQuantityEdit = (barcode: string, newQty: number) => {
- const updated = new Map(editedQuantities);
- updated.set(barcode, newQty);
- setEditedQuantities(updated);
- };
- const handleSelectItem = (barcode: string, checked: boolean) => {
- const updated = new Set(selectedItems);
- if (checked) {
- updated.add(barcode);
- } else {
- updated.delete(barcode);
- }
- setSelectedItems(updated);
- };
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- const allBarcodes = filteredComparisonData
- .filter((item) => item.difference !== 0 && item.variantId)
- .map((item) => item.barcode);
- setSelectedItems(new Set(allBarcodes));
- } else {
- setSelectedItems(new Set());
- }
- };
- const applyInventoryChanges = async () => {
- setApplyingInventory(true);
- setError('');
- try {
- const itemsToApply = filteredComparisonData.filter(
- (item) =>
- selectedItems.has(item.barcode) && item.variantId && item.expectedQuantity !== null,
- );
- if (itemsToApply.length === 0) {
- setError('No items selected to apply');
- setApplyingInventory(false);
- return;
- }
- const variantIds = itemsToApply.map((item) => item.variantId!);
- const inventoryQuery = `query GetInventoryItems($ids: [ID!]!) {
- nodes(ids: $ids) {
- ... on ProductVariant {
- id
- inventoryItem {
- id
- inventoryLevels(first: 10) {
- edges {
- node {
- location {
- id
- name
- }
- quantities(names: [\"available\"]) {
- name
- quantity
- }
- }
- }
- }
- }
- }
- }
- }`;
- const { data: invData, errors: invErrors } = await shopify.query(inventoryQuery, {
- variables: { ids: variantIds },
- });
- if (invErrors && invErrors.length > 0) {
- const errorMessage = invErrors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- setApplyingInventory(false);
- return;
- }
- const changes: any[] = [];
- invData?.nodes?.forEach((node: any, index: number) => {
- if (!node?.inventoryItem) return;
- const item = itemsToApply[index];
- const inventoryItemId = node.inventoryItem.id;
- const levels = node.inventoryItem.inventoryLevels.edges;
- if (levels.length > 0) {
- const level = levels[0].node;
- const locationId = level.location.id;
- const currentQty = level.quantities[0]?.quantity || 0;
- const delta = item.scannedQuantity - currentQty;
- if (delta !== 0) {
- changes.push({
- delta: delta,
- inventoryItemId: inventoryItemId,
- locationId: locationId,
- });
- }
- }
- });
- if (changes.length === 0) {
- setSuccessMessage('No inventory changes needed');
- setTimeout(() => setSuccessMessage(''), 3000);
- confirmApplyModalRef.current?.hideOverlay();
- setApplyingInventory(false);
- return;
- }
- const adjustMutation = `mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!) {
- inventoryAdjustQuantities(input: $input) {
- inventoryAdjustmentGroup {
- id
- reason
- }
- userErrors {
- field
- message
- code
- }
- }
- }`;
- const { data: adjustData, errors: adjustErrors } = await shopify.query(adjustMutation, {
- variables: {
- input: {
- reason: 'correction',
- name: 'available',
- changes: changes,
- },
- },
- });
- if (adjustErrors && adjustErrors.length > 0) {
- const errorMessage = adjustErrors.map((e: any) => e.message).join(', ');
- setError(errorMessage);
- setApplyingInventory(false);
- return;
- }
- if (adjustData?.inventoryAdjustQuantities?.userErrors?.length > 0) {
- const userErrorMessage = adjustData.inventoryAdjustQuantities.userErrors
- .map((e: any) => (e.field ? `${e.field}: ${e.message}` : e.message))
- .join(', ');
- setError(userErrorMessage);
- setApplyingInventory(false);
- return;
- }
- setSuccessMessage(`Successfully updated inventory for ${changes.length} items`);
- setTimeout(() => setSuccessMessage(''), 5000);
- confirmApplyModalRef.current?.hideOverlay();
- setSelectedItems(new Set());
- if (selectedCollectionId) {
- await fetchCollectionInventory(selectedCollectionId);
- }
- } catch (err: any) {
- setError(err.message || 'Failed to apply inventory changes');
- } finally {
- setApplyingInventory(false);
- }
- };
- const generateCSV = (data: ComparisonItem[]): string => {
- const escapeCSV = (value: string | number | null): string => {
- if (value === null || value === undefined) return '';
- const stringValue = String(value);
- if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
- return `"${stringValue.replace(/"/g, '""')}"`;
- }
- return stringValue;
- };
- const headers = [
- 'Product',
- 'Variant',
- 'SKU',
- 'Barcode',
- 'Expected Quantity',
- 'Scanned Quantity',
- 'Difference',
- ];
- const rows = data.map((item) => [
- escapeCSV(item.productTitle),
- escapeCSV(item.variantTitle),
- escapeCSV(item.sku),
- escapeCSV(item.barcode),
- escapeCSV(item.expectedQuantity !== null ? item.expectedQuantity : 'N/A'),
- escapeCSV(item.scannedQuantity),
- escapeCSV(item.difference !== null ? item.difference : 'N/A'),
- ]);
- const csvContent = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
- return csvContent;
- };
- const getCSVFilename = (): string => {
- const currentSession = sessions.find((s) => s.id === currentSessionId);
- const sessionName = currentSession?.name || 'stock-take';
- const date = new Date().toISOString().split('T')[0];
- const sanitizedName = sessionName.replace(/[^a-z0-9]/gi, '-').toLowerCase();
- return `${sanitizedName}-${date}.csv`;
- };
- const comparisonData = getComparisonData();
- const filteredComparisonData = showOnlyDiscrepancies
- ? comparisonData.filter((item) => item.difference !== 0)
- : comparisonData;
- const selectedCount = filteredComparisonData.filter((item) =>
- selectedItems.has(item.barcode),
- ).length;
- const csvDataUrl =
- comparisonData.length > 0
- ? `data:text/csv;charset=utf-8,${encodeURIComponent(generateCSV(comparisonData))}`
- : '';
- return (
- <s-page heading="Stock Take">
- {error && (
- <s-banner tone="critical" dismissible onDismiss={() => setError('')}>
- {error}
- </s-banner>
- )}
- {successMessage && (
- <s-banner tone="success" dismissible onDismiss={() => setSuccessMessage('')}>
- {successMessage}
- </s-banner>
- )}
- <s-section heading="Session Management">
- <s-stack gap="base" direction="inline" alignItems="end">
- <s-select
- id="session-select"
- label="Active Session"
- value={currentSessionId}
- onChange={handleSessionChange}
- disabled={loadingSessions}
- placeholder="Select a session"
- >
- <s-option value="">Select a session</s-option>
- {sessions.map((session) => (
- <s-option key={session.id} value={session.id}>
- {session.name} ({new Date(session.createdDate).toLocaleDateString()})
- </s-option>
- ))}
- </s-select>
- <s-button
- id="new-session-button"
- variant="primary"
- commandFor="new-session-modal"
- command="--show"
- disabled={!definitionReady}
- >
- New Stock Take
- </s-button>
- {currentSessionId && (
- <s-button
- id="delete-session-button"
- variant="secondary"
- tone="critical"
- commandFor="confirm-delete-modal"
- command="--show"
- >
- Delete Session
- </s-button>
- )}
- </s-stack>
- </s-section>
- {!currentSessionId ? (
- <s-section>
- <s-text color="subdued">Create or select a stock take session to begin.</s-text>
- </s-section>
- ) : (
- <>
- <s-section heading="Scan Items">
- <s-stack gap="base">
- <s-stack direction="inline" gap="base" alignItems="end">
- <s-text-field
- id="barcode-input"
- label="Scan Barcode"
- value={barcodeInput}
- onInput={handleBarcodeInput}
- placeholder="Scan barcode or type and press Enter"
- icon="barcode"
- />
- <s-button
- id="clear-button"
- variant="secondary"
- onClick={handleClearScans}
- disabled={scannedItems.length === 0}
- >
- Clear All Scans
- </s-button>
- </s-stack>
- <s-section padding="none">
- <s-box padding="base">
- <s-heading>Scanned Items ({scannedItems.length})</s-heading>
- </s-box>
- <s-table id="scanned-table">
- <s-table-header-row>
- <s-table-header listSlot="primary">Product</s-table-header>
- <s-table-header listSlot="secondary">Variant</s-table-header>
- <s-table-header listSlot="labeled">SKU</s-table-header>
- <s-table-header listSlot="labeled">Barcode</s-table-header>
- <s-table-header listSlot="labeled" format="numeric">
- Quantity
- </s-table-header>
- </s-table-header-row>
- <s-table-body>
- {scannedItems.length === 0 ? (
- <s-table-row>
- <s-table-cell>
- <s-text color="subdued">
- No items scanned yet. Scan a barcode to begin.
- </s-text>
- </s-table-cell>
- </s-table-row>
- ) : (
- scannedItems.map((item, index) => (
- <s-table-row key={`${item.barcode}-${index}`}>
- <s-table-cell>{item.productTitle}</s-table-cell>
- <s-table-cell>{item.variantTitle}</s-table-cell>
- <s-table-cell>{item.sku}</s-table-cell>
- <s-table-cell>{item.barcode}</s-table-cell>
- <s-table-cell>{item.quantity}</s-table-cell>
- </s-table-row>
- ))
- )}
- </s-table-body>
- </s-table>
- </s-section>
- </s-stack>
- </s-section>
- <s-section heading="Collection Inventory">
- <s-stack gap="base">
- <s-select
- id="collection-select"
- label="Select Collection"
- value={selectedCollectionId}
- onChange={handleCollectionChange}
- disabled={loadingCollections}
- placeholder="Choose a collection"
- >
- <s-option value="">Select a collection</s-option>
- {collections.map((collection) => (
- <s-option key={collection.id} value={collection.id}>
- {collection.title}
- </s-option>
- ))}
- </s-select>
- {selectedCollectionId && (
- <s-section padding="none">
- <s-box padding="base">
- <s-heading>Collection Items ({collectionVariants.length})</s-heading>
- </s-box>
- <s-table id="collection-table" loading={loadingCollection}>
- <s-table-header-row>
- <s-table-header listSlot="primary">Product</s-table-header>
- <s-table-header listSlot="secondary">Variant</s-table-header>
- <s-table-header listSlot="labeled">SKU</s-table-header>
- <s-table-header listSlot="labeled">Barcode</s-table-header>
- <s-table-header listSlot="labeled" format="numeric">
- Expected Qty
- </s-table-header>
- </s-table-header-row>
- <s-table-body>
- {collectionVariants.length === 0 && !loadingCollection ? (
- <s-table-row>
- <s-table-cell>
- <s-text color="subdued">No items in this collection.</s-text>
- </s-table-cell>
- </s-table-row>
- ) : (
- collectionVariants.map((variant, index) => (
- <s-table-row key={`${variant.barcode}-${index}`}>
- <s-table-cell>{variant.productTitle}</s-table-cell>
- <s-table-cell>{variant.variantTitle}</s-table-cell>
- <s-table-cell>{variant.sku}</s-table-cell>
- <s-table-cell>{variant.barcode}</s-table-cell>
- <s-table-cell>{variant.inventoryQuantity}</s-table-cell>
- </s-table-row>
- ))
- )}
- </s-table-body>
- </s-table>
- </s-section>
- )}
- </s-stack>
- </s-section>
- <s-section heading="Comparison">
- {!selectedCollectionId ? (
- <s-text color="subdued">Select a collection to compare scanned items.</s-text>
- ) : (
- <s-stack gap="base">
- <s-stack direction="inline" gap="base" alignItems="center">
- <s-checkbox
- id="filter-checkbox"
- label="Show only discrepancies"
- checked={showOnlyDiscrepancies}
- onChange={(e: Event) =>
- setShowOnlyDiscrepancies((e.currentTarget as HTMLInputElement).checked)
- }
- />
- {comparisonData.length > 0 && csvDataUrl && (
- <s-link id="export-csv-link" href={csvDataUrl} download={getCSVFilename()}>
- <s-button id="export-csv-button" variant="secondary">
- Export to CSV
- </s-button>
- </s-link>
- )}
- {filteredComparisonData.filter((item) => item.difference !== 0 && item.variantId)
- .length > 0 && (
- <s-button
- id="apply-inventory-button"
- variant="primary"
- commandFor="confirm-apply-modal"
- command="--show"
- disabled={selectedCount === 0}
- >
- Apply to Inventory ({selectedCount} selected)
- </s-button>
- )}
- </s-stack>
- <s-section padding="none">
- <s-box padding="base">
- <s-stack direction="inline" gap="base" alignItems="center">
- <s-heading>Comparison Results ({filteredComparisonData.length})</s-heading>
- {filteredComparisonData.filter(
- (item) => item.difference !== 0 && item.variantId,
- ).length > 0 && (
- <s-checkbox
- id="select-all-checkbox"
- label="Select all"
- checked={
- selectedCount ===
- filteredComparisonData.filter(
- (item) => item.difference !== 0 && item.variantId,
- ).length
- }
- onChange={(e: Event) =>
- handleSelectAll((e.currentTarget as HTMLInputElement).checked)
- }
- />
- )}
- </s-stack>
- </s-box>
- <s-table id="comparison-table">
- <s-table-header-row>
- <s-table-header listSlot="labeled">Select</s-table-header>
- <s-table-header listSlot="primary">Product</s-table-header>
- <s-table-header listSlot="secondary">Variant</s-table-header>
- <s-table-header listSlot="labeled">SKU</s-table-header>
- <s-table-header listSlot="labeled">Barcode</s-table-header>
- <s-table-header listSlot="labeled" format="numeric">
- Expected
- </s-table-header>
- <s-table-header listSlot="labeled" format="numeric">
- Scanned
- </s-table-header>
- <s-table-header listSlot="labeled" format="numeric">
- Difference
- </s-table-header>
- </s-table-header-row>
- <s-table-body>
- {filteredComparisonData.length === 0 ? (
- <s-table-row>
- <s-table-cell>
- <s-text color="subdued">
- {showOnlyDiscrepancies
- ? 'No discrepancies found. All scanned quantities match expected inventory.'
- : 'No data to compare.'}
- </s-text>
- </s-table-cell>
- </s-table-row>
- ) : (
- filteredComparisonData.map((item, index) => (
- <s-table-row key={`${item.barcode}-${index}`}>
- <s-table-cell>
- {item.difference !== 0 && item.variantId ? (
- <s-checkbox
- id={`select-${item.barcode}`}
- checked={item.selected}
- onChange={(e: Event) =>
- handleSelectItem(
- item.barcode,
- (e.currentTarget as HTMLInputElement).checked,
- )
- }
- />
- ) : null}
- </s-table-cell>
- <s-table-cell>{item.productTitle}</s-table-cell>
- <s-table-cell>{item.variantTitle}</s-table-cell>
- <s-table-cell>{item.sku}</s-table-cell>
- <s-table-cell>{item.barcode}</s-table-cell>
- <s-table-cell>
- {item.expectedQuantity !== null ? item.expectedQuantity : 'N/A'}
- </s-table-cell>
- <s-table-cell>
- <s-number-field
- id={`qty-${item.barcode}`}
- value={item.scannedQuantity.toString()}
- onInput={(e: Event) =>
- handleQuantityEdit(
- item.barcode,
- parseInt((e.currentTarget as HTMLInputElement).value) || 0,
- )
- }
- min={0}
- />
- </s-table-cell>
- <s-table-cell>
- {item.difference !== null ? (
- <s-badge
- tone={
- item.difference === 0
- ? 'success'
- : item.difference > 0
- ? 'warning'
- : 'critical'
- }
- >
- {item.difference > 0 ? '+' : ''}
- {item.difference}
- </s-badge>
- ) : (
- <s-text color="subdued">N/A</s-text>
- )}
- </s-table-cell>
- </s-table-row>
- ))
- )}
- </s-table-body>
- </s-table>
- </s-section>
- </s-stack>
- )}
- </s-section>
- </>
- )}
- <s-modal ref={newSessionModalRef} id="new-session-modal" heading="Create New Stock Take">
- <s-text-field
- id="session-name-input"
- label="Session Name"
- value={newSessionName}
- onInput={(e: Event) => setNewSessionName((e.currentTarget as HTMLInputElement).value)}
- placeholder="e.g., Monthly Stock Take - January 2024"
- />
- <s-button
- slot="secondary-actions"
- id="cancel-session-button"
- variant="secondary"
- commandFor="new-session-modal"
- command="--hide"
- >
- Cancel
- </s-button>
- <s-button
- slot="primary-action"
- id="create-session-button"
- variant="primary"
- loading={savingSession}
- onClick={createNewSession}
- disabled={!newSessionName.trim()}
- >
- Create Session
- </s-button>
- </s-modal>
- <s-modal
- ref={confirmApplyModalRef}
- id="confirm-apply-modal"
- heading="Confirm Inventory Update"
- >
- <s-text>
- You are about to update inventory for {selectedCount} item(s). This will adjust the
- available inventory to match the scanned quantities. Do you want to continue?
- </s-text>
- <s-button
- slot="secondary-actions"
- id="cancel-apply-button"
- variant="secondary"
- commandFor="confirm-apply-modal"
- command="--hide"
- >
- Cancel
- </s-button>
- <s-button
- slot="primary-action"
- id="confirm-apply-button"
- variant="primary"
- loading={applyingInventory}
- onClick={applyInventoryChanges}
- >
- Apply Changes
- </s-button>
- </s-modal>
- <s-modal
- ref={confirmDeleteModalRef}
- id="confirm-delete-modal"
- heading="Delete Stock Take Session"
- >
- <s-text>
- Are you sure you want to delete this stock take session? This action cannot be undone.
- </s-text>
- <s-button
- slot="secondary-actions"
- id="cancel-delete-button"
- variant="secondary"
- commandFor="confirm-delete-modal"
- command="--hide"
- >
- Cancel
- </s-button>
- <s-button
- slot="primary-action"
- id="confirm-delete-button"
- variant="primary"
- tone="critical"
- loading={deletingSession}
- onClick={deleteSession}
- >
- Delete Session
- </s-button>
- </s-modal>
- </s-page>
- );
- }
- export default (): void => render(<Extension />, document.body);
Advertisement
Add Comment
Please, Sign In to add comment