Initial commit
Load this up somewhere where I can setup CI/CD
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { ReactNode, useContext } from 'react';
|
||||
|
||||
import { Bin, binToLabel } from '../../../../lib/bin.enum';
|
||||
import { BinnerContext } from '../../context/BinnerContext';
|
||||
|
||||
interface BinButtonProps {
|
||||
bin: Bin;
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function BinButton({ bin, className, icon }: BinButtonProps) {
|
||||
const { item, setBin } = useContext(BinnerContext);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button disabled={!item} onClick={() => setBin(bin)}>
|
||||
{icon && <span>{icon}</span>}
|
||||
<strong>{binToLabel(bin)}</strong>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.bins {
|
||||
display: flex;
|
||||
flex: 1 0 100%;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
.pdsBinWrap {
|
||||
display: flex;
|
||||
flex: 0 0 50%;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.bin {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex: 1 1 33%;
|
||||
|
||||
button {
|
||||
font-size: 1.3em;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lossBinWrap {
|
||||
display: flex;
|
||||
flex: 0 0 50%;
|
||||
|
||||
button {
|
||||
font-size: 1.3em;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.iconBottom, .iconLeft, .iconRight, .iconTop {
|
||||
button {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBottom, .iconTop {
|
||||
button {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.iconLeft, .iconRight {
|
||||
button {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.iconRight, .iconBottom {
|
||||
strong {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
span {
|
||||
order: 2
|
||||
}
|
||||
}
|
||||
60
src/app/(authenticated)/binner/components/Bins/Bins.tsx
Normal file
60
src/app/(authenticated)/binner/components/Bins/Bins.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowDown, faArrowLeft, faArrowRight, faArrowUp } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { Bin } from '../../../../lib/bin.enum';
|
||||
import { BinButton } from '../BinButton/BinButton';
|
||||
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { BinnerContext } from '../../context/BinnerContext';
|
||||
|
||||
import styles from './Bins.module.scss';
|
||||
|
||||
export function Bins() {
|
||||
const { bin, item, setBin, setItem } = useContext(BinnerContext);
|
||||
|
||||
const handleKeydown = useCallback((event: KeyboardEvent) => {
|
||||
console.log('Bins Keydown!');
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
console.log('Loss!');
|
||||
setBin(Bin.LOSS);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
console.log('Process!');
|
||||
setBin(Bin.PROCESS);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
console.log('Shoulder Tap!');
|
||||
setBin(Bin.SHOULDER_TAP);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
console.log('Donate!');
|
||||
setBin(Bin.DONATE);
|
||||
break;
|
||||
}
|
||||
}, [bin, item, setBin, setItem]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.bins}>
|
||||
<div className={styles.pdsBinWrap}>
|
||||
<BinButton className={clsx(styles.bin, styles.pBin, styles.iconLeft)} bin={Bin.PROCESS} icon={<FontAwesomeIcon icon={faArrowLeft} />} />
|
||||
<BinButton className={clsx(styles.bin, styles.dBin, styles.iconTop)} bin={Bin.DONATE} icon={<FontAwesomeIcon icon={faArrowUp} />} />
|
||||
<BinButton className={clsx(styles.bin, styles.sBin, styles.iconRight)} bin={Bin.SHOULDER_TAP} icon={<FontAwesomeIcon icon={faArrowRight} />} />
|
||||
</div>
|
||||
<BinButton className={clsx(styles.lossBinWrap, styles.lBin, styles.iconBottom)} bin={Bin.LOSS} icon={<FontAwesomeIcon icon={faArrowDown} />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.defectPanel {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
padding: 1rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.defectHeader {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Bin } from '@/app/lib/bin.enum';
|
||||
import { DetailsForm } from '../DetailsForm/DetailsForm';
|
||||
import { BinnerContext } from '../../context/BinnerContext';
|
||||
|
||||
import styles from './DefectPanel.module.scss';
|
||||
|
||||
export function DefectPanel() {
|
||||
const { bin, setBin } = useContext(BinnerContext);
|
||||
|
||||
if (bin === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.defectPanel}>
|
||||
<header className={styles.defectHeader}>
|
||||
<button onClick={() => setBin(null)} aria-label="Press Escape to go back">×</button>
|
||||
<span><strong>{Bin[bin]} Bin</strong></span>
|
||||
</header>
|
||||
<DetailsForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.detailForm {
|
||||
|
||||
button, button.selected {
|
||||
&:active {
|
||||
background-color: #ff6900;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: #ff6900;
|
||||
background-color: rgb(24,48,40);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
&:focus-visible {
|
||||
outline-color: #ff6900;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fieldset {
|
||||
margin: 0.5rem 0;
|
||||
padding: 1rem;
|
||||
|
||||
button {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
label, legend {
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.5rem;
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonWrap {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.defectBtn {
|
||||
font-size: 1.3em;
|
||||
font-weight: 700;
|
||||
padding: 0.5em;
|
||||
|
||||
&.selected {
|
||||
background-color: #ff6900;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.selectWrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
font-size: 1.3em;
|
||||
margin: 1rem 0;
|
||||
padding: 0.5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// rgba(24,48,40,var(--tw-bg-opacity));
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useSupplyChainData } from '@/app/lib/context/SupplyChain/hooks';
|
||||
import { Defect, defectToLabel } from '@/app/lib/defect.enum';
|
||||
import { useBinHandler } from './hooks';
|
||||
|
||||
import styles from './DetailForm.module.scss';
|
||||
|
||||
export function DetailsForm() {
|
||||
const { farms } = useSupplyChainData();
|
||||
|
||||
const [farm, setFarm] = useState<string | null>(null);
|
||||
const [defect, setDefect] = useState<Defect | null>(null);
|
||||
|
||||
const binHandler = useBinHandler();
|
||||
|
||||
// Track keyboard focus state
|
||||
const [focusedDefect, setFocusedDefect] = useState<number | null>(null);
|
||||
|
||||
// Refs for accessibility
|
||||
const addFarmRef = useRef<HTMLButtonElement>(null);
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
const defectRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const defectsWrapRef = useRef<HTMLFieldSetElement>(null);
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const waiReasonDescId = 'waiReasonDescription';
|
||||
const defectCodes = Object.keys(Defect).slice(0, Object.keys(Defect).length / 2).map((code) => parseInt(code));
|
||||
|
||||
const handleDefectKeydown = useCallback((event: React.KeyboardEvent<HTMLFieldSetElement>) => {
|
||||
if (focusedDefect !== null) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
setFocusedDefect(focusedDefect + 1 > defectRefs.current.length - 1 ? 0 : focusedDefect + 1);
|
||||
}
|
||||
if (focusedDefect !== null && event.key === 'ArrowUp') {
|
||||
setFocusedDefect(focusedDefect - 1 < 0 ? defectRefs.current.length - 1 : focusedDefect - 1);
|
||||
}
|
||||
} else {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
setFocusedDefect(0);
|
||||
}
|
||||
}
|
||||
}, [focusedDefect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitRef.current) {
|
||||
submitRef.current.focus();
|
||||
}
|
||||
}, [defect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedDefect !== null && defectRefs.current[focusedDefect]) {
|
||||
defectRefs.current[focusedDefect]?.focus();
|
||||
}
|
||||
}, [focusedDefect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectRef.current) {
|
||||
selectRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.detailForm}>
|
||||
<fieldset className={styles.fieldset} onKeyDown={(e) => e.key === 'ArrowRight' && addFarmRef.current?.click()}>
|
||||
<legend>Farm Information</legend>
|
||||
<div className={styles.selectWrap}>
|
||||
<label htmlFor="farm">Select source farm</label>
|
||||
<select name="farm" id="farm" onChange={(event) => setFarm(event.target.value)} ref={selectRef} defaultValue="">
|
||||
<option value="" disabled>Select a farm</option>
|
||||
{farms.map((farm) => <option key={farm._id} value={farm._id}>{farm.name}</option>)}
|
||||
</select>
|
||||
<button onClick={() => alert('Users could add a farm where one does not exist!')} tabIndex={-1} ref={addFarmRef}>Add new farm</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset className={styles.fieldset} onKeyDown={handleDefectKeydown} ref={defectsWrapRef}>
|
||||
<legend id={waiReasonDescId}>Select a reason for binning</legend>
|
||||
<div className={styles.buttonWrap}>
|
||||
{defectCodes.map((code, i) => (
|
||||
<button
|
||||
key={code}
|
||||
className={clsx(styles.defectBtn, defect === code && styles.selected)}
|
||||
aria-describedby={waiReasonDescId}
|
||||
onClick={() => setDefect(code)}
|
||||
ref={(el) => defectRefs.current[code] = el}
|
||||
tabIndex={i === 0 ? 0 : -1}
|
||||
>
|
||||
{defectToLabel(code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button className={styles.submitBtn} disabled={defect === null} onClick={() => binHandler(farm, defect as Defect)} ref={submitRef}>Bin It!</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { StringSchemaDefinition } from 'mongoose';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
import { BinnerContext } from '../../context/BinnerContext';
|
||||
import { useGetProcessorIdByBarcodeId } from '../../hooks';
|
||||
import { addToBin } from '@/app/lib/data/binned';
|
||||
import { Defect } from '@/app/lib/defect.enum';
|
||||
import { Product } from '@/app/lib/product.enum';
|
||||
|
||||
export const useBinHandler = () => {
|
||||
const { bin, item, setBin, setItem } = useContext(BinnerContext);
|
||||
const getProcessorIdByBarcodeId = useGetProcessorIdByBarcodeId();
|
||||
|
||||
return useCallback(async (farm: string | null, reason: Defect) => {
|
||||
if (!item || bin === null) return;
|
||||
|
||||
const binnedItem = {
|
||||
...item,
|
||||
farm: farm as StringSchemaDefinition || undefined,
|
||||
operator: faker.helpers.arrayElement(['Steve', 'Leighann', 'Lwanda', 'Xueliang', 'Jose']),
|
||||
processor: getProcessorIdByBarcodeId(item.processor) as StringSchemaDefinition,
|
||||
reason,
|
||||
};
|
||||
await addToBin(bin, binnedItem);
|
||||
setBin(null);
|
||||
setItem(null);
|
||||
}, [bin, item, setBin, setItem]);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
.productInfo {
|
||||
padding: 1rem;
|
||||
|
||||
label {
|
||||
font-weight: 700;
|
||||
font-size: 1.3em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
input, select {
|
||||
caret-color: transparent;
|
||||
font-size: 1.36em;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&[contenteditable=false] {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attributes {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 45%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.inputWithButton {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
import { dateToOrdinal, ordinalToDate } from '@/app/lib/ordinalDate';
|
||||
import { Product } from '@/app/lib/product.enum';
|
||||
import { SupplyChainContext } from '@/app/lib/context/SupplyChain/SupplyChainContext';
|
||||
import { ParsedBarcode, barcodeToProduct } from '@/app/lib/barcode';
|
||||
|
||||
import { BinnerContext } from '../../context/BinnerContext';
|
||||
import { useGetProcessorByBarcodeId } from '../../hooks';
|
||||
|
||||
import styles from './ProductInfo.module.scss';
|
||||
|
||||
export function ProductInfo() {
|
||||
const { item, setItem } = useContext(BinnerContext);
|
||||
const { processors } = useContext(SupplyChainContext);
|
||||
const [inputItem, setInputItem] = useState<Partial<ParsedBarcode> | null>(null);
|
||||
const dateRef = useRef<HTMLInputElement>(null);
|
||||
const weightRef = useRef<HTMLInputElement>(null);
|
||||
const getProcessorByBarcodeId = useGetProcessorByBarcodeId();
|
||||
|
||||
const isCompleteItem = (item: Partial<ParsedBarcode> | null) => item && !!(item?.product && item?.date && item?.processor && item?.weight);
|
||||
|
||||
const [isEntryMode, setIsEntryMode] = useState(!isCompleteItem(item));
|
||||
|
||||
useEffect(() => {
|
||||
setIsEntryMode(!isCompleteItem(item));
|
||||
}, [item])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleteItem(inputItem)) {
|
||||
setItem(barcodeToProduct(`${inputItem?.product}${inputItem?.date}${inputItem?.processor}${inputItem?.weight}`));
|
||||
setInputItem(null);
|
||||
}
|
||||
setIsEntryMode(!isCompleteItem(inputItem));
|
||||
}, [inputItem])
|
||||
|
||||
return (
|
||||
<div className={styles.productInfo}>
|
||||
<div className={styles.attributes}>
|
||||
<div className={styles.card}>
|
||||
<label htmlFor="product">Product</label>
|
||||
{isEntryMode ? (
|
||||
<select
|
||||
id="product"
|
||||
defaultValue=""
|
||||
onChange={(e) => setInputItem({ ...inputItem || {}, product: e.currentTarget.value })}
|
||||
>
|
||||
<option value="">Select a product</option>
|
||||
{Object.keys(Product).map((product) => (
|
||||
<option key={product} value={Product[product]}>{product}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input id="product" value={item?.product!} contentEditable={false} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<label htmlFor="date">Packaged Date</label>
|
||||
<input
|
||||
id="date" value={!!item ? ordinalToDate(item.date).toDateString() : ''}
|
||||
contentEditable={isEntryMode}
|
||||
ref={dateRef}
|
||||
onChange={(e) => {
|
||||
dateRef.current.value = e.currentTarget.value;
|
||||
setInputItem({ ...inputItem || {}, date: dateToOrdinal(new Date(e.currentTarget.value)) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<label htmlFor="processor">Processor</label>
|
||||
{isEntryMode ? (
|
||||
<select
|
||||
id="processor"
|
||||
defaultValue=""
|
||||
onChange={(e) => setInputItem({ ...inputItem || {}, processor: e.currentTarget.value })}
|
||||
>
|
||||
<option value="">Select a processor</option>
|
||||
{processors.map((processor) => (
|
||||
<option key={processor._id} value={processor.barcode_id}>{processor.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input id="processor" value={!!item ? (getProcessorByBarcodeId(item.processor)?.name ?? 'unknown/invalid processor') : ''} contentEditable={false} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<label htmlFor="weight">Weight</label>
|
||||
<div className={styles.inputWithButton}>
|
||||
<input
|
||||
id="weight"
|
||||
value={!!item ? `${item.weight} lbs.` : ''}
|
||||
contentEditable={false}
|
||||
tabIndex={-1}
|
||||
ref={weightRef}
|
||||
/>
|
||||
{isEntryMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const weight = faker.number.int({ min: 1000, max: 1999 });
|
||||
weightRef.current.value = `${weight / 100} lbs`; setInputItem({ ...inputItem || {}, weight })
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
Weight from Scale
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user