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>
|
||||
);
|
||||
}
|
||||
13
src/app/(authenticated)/binner/context/BinnerContext.ts
Normal file
13
src/app/(authenticated)/binner/context/BinnerContext.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { Bin } from '../../../lib/bin.enum';
|
||||
import { ParsedBarcode } from '@/app/lib/barcode';
|
||||
|
||||
interface BinnerContext {
|
||||
bin: Bin | null;
|
||||
item: ParsedBarcode | null;
|
||||
setBin: Dispatch<SetStateAction<Bin | null>>;
|
||||
setItem: Dispatch<SetStateAction<ParsedBarcode | null>>;
|
||||
}
|
||||
|
||||
export const BinnerContext = createContext<BinnerContext>({ bin: null, item: null } as BinnerContext);
|
||||
17
src/app/(authenticated)/binner/context/BinnerProvider.tsx
Normal file
17
src/app/(authenticated)/binner/context/BinnerProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BinnerContext } from './BinnerContext';
|
||||
import { Bin } from '@/app/lib/bin.enum';
|
||||
import { ParsedBarcode } from '@/app/lib/barcode';
|
||||
|
||||
export const BinnerProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const [bin, setBin] = useState<Bin | null>(null);
|
||||
const [item, setItem] = useState<ParsedBarcode | null>(null);
|
||||
|
||||
return (
|
||||
<BinnerContext.Provider value={{ bin, item, setBin, setItem }}>
|
||||
{children}
|
||||
</BinnerContext.Provider>
|
||||
);
|
||||
};
|
||||
23
src/app/(authenticated)/binner/hooks.ts
Normal file
23
src/app/(authenticated)/binner/hooks.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
|
||||
import { SupplyChainContext } from '@/app/lib/context/SupplyChain/SupplyChainContext';
|
||||
import { Processor } from '@/models/processor';
|
||||
|
||||
export const useGetProcessorByBarcodeId = () => {
|
||||
const { processors } = useContext(SupplyChainContext);
|
||||
|
||||
return useCallback((barcodeId?: string) => {
|
||||
if (!barcodeId) return null;
|
||||
const processor = processors?.find((processor: Processor) => processor.barcode_id === barcodeId);
|
||||
return processor as Processor & { _id: string } || null;
|
||||
}, [processors]);
|
||||
};
|
||||
|
||||
export const useGetProcessorIdByBarcodeId = () => {
|
||||
const getProcessorByBarcodeId = useGetProcessorByBarcodeId();
|
||||
|
||||
return useCallback((barcodeId?: string) => {
|
||||
if (!barcodeId) return null;
|
||||
return getProcessorByBarcodeId(barcodeId)?._id || null;
|
||||
}, [useGetProcessorByBarcodeId]);
|
||||
};
|
||||
11
src/app/(authenticated)/binner/layout.tsx
Normal file
11
src/app/(authenticated)/binner/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { BinnerProvider } from './context/BinnerProvider';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<BinnerProvider>
|
||||
{children}
|
||||
</BinnerProvider>
|
||||
);
|
||||
}
|
||||
21
src/app/(authenticated)/binner/page.module.scss
Normal file
21
src/app/(authenticated)/binner/page.module.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background-color: #ff6900;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interactionPanel {
|
||||
display: flex;
|
||||
flex: 0 0 70%;
|
||||
position: relative;
|
||||
}
|
||||
51
src/app/(authenticated)/binner/page.tsx
Normal file
51
src/app/(authenticated)/binner/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import { barcodeToProduct, generateRandomBarcode } from '../../lib/barcode';
|
||||
import { ProductInfo } from './components/ProductInfo/ProductInfo';
|
||||
import { Bins } from './components/Bins/Bins';
|
||||
import { DefectPanel } from './components/DefectPanel/DefectPanel';
|
||||
import { BinnerContext } from './context/BinnerContext';
|
||||
|
||||
import styles from './page.module.scss';
|
||||
|
||||
export default function Page() {
|
||||
const { bin, item, setBin, setItem } = useContext(BinnerContext);
|
||||
|
||||
const handleKeydown = useCallback((event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
console.log('Simulating barcode read');
|
||||
const barcode = generateRandomBarcode();
|
||||
setItem(barcodeToProduct(barcode));
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
console.log('Clearing bin selection');
|
||||
setBin(null);
|
||||
break;
|
||||
}
|
||||
}, [bin, item, setBin, setItem]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.banner}>Use the spacebar to simulate a barcode read</div>
|
||||
<ProductInfo />
|
||||
<div className={styles.interactionPanel}>
|
||||
{bin !== null
|
||||
? <DefectPanel />
|
||||
: <Bins />
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
46
src/app/(authenticated)/dashboard/bins/page.module.scss
Normal file
46
src/app/(authenticated)/dashboard/bins/page.module.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
@import '../page.module.scss';
|
||||
|
||||
.page {
|
||||
padding: 1rem;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
@extend .header;
|
||||
}
|
||||
|
||||
.bins {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.bin {
|
||||
border: 1px dotted rgba(0,0,0,0.7);
|
||||
border-radius: 5px;
|
||||
flex: 1 1 45%;
|
||||
padding: 0.5rem;
|
||||
height: calc(100vh / 3);
|
||||
overflow: hidden;
|
||||
|
||||
ul {
|
||||
border-top: 1px solid rgba(0,0,0,0.7);
|
||||
margin-top: 0.5rem;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0,0,255,0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/app/(authenticated)/dashboard/bins/page.tsx
Normal file
61
src/app/(authenticated)/dashboard/bins/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { BinsContents, getBinsContents, switchBin } from '@/app/lib/data/binned';
|
||||
import { Binned, BinnedDocument } from '@/models/binned';
|
||||
import { binToLabel } from '@/app/lib/bin.enum';
|
||||
|
||||
import styles from './page.module.scss';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Page() {
|
||||
const [bins, setBins] = useState<BinsContents>();
|
||||
const [dragging, setDragging] = useState<Binned>();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const bins = await getBinsContents();
|
||||
setBins(bins);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleDrop = async (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (!dragging || !event.currentTarget.id) return;
|
||||
alert(`If this were functionally complete, the item would be moved from ${binToLabel(dragging.bin)} to ${binToLabel(parseInt(event.currentTarget.id))}`);
|
||||
await switchBin(parseInt(event.currentTarget.id), (dragging as BinnedDocument)._id);
|
||||
setDragging(undefined);
|
||||
};
|
||||
|
||||
if (!bins) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<h1>Bin Contents</h1>
|
||||
<Link href="/dashboard"><button>< Dashboard</button></Link>
|
||||
</header>
|
||||
<div className={styles.bins}>
|
||||
{Object.entries(bins).map(([k, v]) => (
|
||||
<div
|
||||
key={k}
|
||||
id={k}
|
||||
className={styles.bin}
|
||||
onDragEnter={(event) => event.preventDefault()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<h2>{binToLabel(parseInt(k))}</h2>
|
||||
{v.length > 0 && (
|
||||
<ul onDrop={handleDrop}>
|
||||
{v.map((item) => (<li key={(item as BinnedDocument)._id} onDragStart={() => setDragging(item)} draggable>{`${item.product} - ${item.weight}lbs`}</li>))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { BinWeights, getAggregateBinWeights } from '@/app/lib/data/dashboard';
|
||||
import { binToLabel } from '@/app/lib/bin.enum';
|
||||
|
||||
import { BarChart } from './BarChart/BarChart';
|
||||
|
||||
export function AggregateBinWeightsChart() {
|
||||
const [binWeights, setBinWeights] = useState<BinWeights>({} as BinWeights);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const binWeights = await getAggregateBinWeights();
|
||||
setBinWeights(binWeights);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={{
|
||||
labels: Object.keys(binWeights).map((k) => binToLabel(parseInt(k))),
|
||||
datasets: [{ data: Object.values(binWeights) }],
|
||||
}}
|
||||
opts={{
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `${context.formattedValue} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
title='Aggregate Bin Weights'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.title {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ChartData,
|
||||
ChartOptions,
|
||||
} from 'chart.js';
|
||||
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
|
||||
import styles from './BarChart.module.scss';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface BarChartOpts {
|
||||
className?: string;
|
||||
data: ChartData<"bar">;
|
||||
opts: ChartOptions<"bar">;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function BarChart({ className, data, opts, title }: BarChartOpts) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{title && <h2 className={styles.title}>{title}</h2>}
|
||||
<Bar data={data} options={opts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { CategorizedOperatorLoss, getLossCategoriesByOperator } from '@/app/lib/data/dashboard';
|
||||
|
||||
import { BarChart } from './BarChart/BarChart';
|
||||
import { binToLabel } from '@/app/lib/bin.enum';
|
||||
import { ChartDataset } from 'chart.js';
|
||||
|
||||
const dataToDataset = (data: CategorizedOperatorLoss) => {
|
||||
const labels = [];
|
||||
const datasets: ChartDataset<"bar">[] = [];
|
||||
const bgColors = ['red', 'orange', 'blue', 'green'];
|
||||
for (const [operator, binWeights] of Object.entries(data)) {
|
||||
labels.push(operator);
|
||||
Object.entries(binWeights).forEach(([bin, weight]) => {
|
||||
datasets[parseInt(bin)] = {
|
||||
label: datasets[parseInt(bin)]?.label || binToLabel(parseInt(bin)),
|
||||
data: [...datasets[parseInt(bin)]?.data || [], weight],
|
||||
backgroundColor: datasets[parseInt(bin)]?.backgroundColor || bgColors[parseInt(bin)],
|
||||
stack: 'bar',
|
||||
};
|
||||
});
|
||||
}
|
||||
return { labels, datasets };
|
||||
};
|
||||
|
||||
export function CategorizedLossByOperatorChart() {
|
||||
const [operatorLoss, setOperatorLoss] = useState<CategorizedOperatorLoss>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const operatorLoss = await getLossCategoriesByOperator();
|
||||
setOperatorLoss(operatorLoss);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={dataToDataset(operatorLoss)}
|
||||
opts={{
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `${context.formattedValue} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value} lbs`,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
}
|
||||
},
|
||||
}}
|
||||
title='Categorized Loss by Operator'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { CategorizedLoss } from '@/app/lib/data/dashboard';
|
||||
|
||||
import { BarChart } from './BarChart/BarChart';
|
||||
|
||||
export function CategorizedLossChart({ dataGetter, title }: { dataGetter: () => Promise<CategorizedLoss>, title: string }) {
|
||||
const [categorizedLoss, setCategorizedLoss] = useState<CategorizedLoss>({});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const categorizedLoss = await dataGetter();
|
||||
setCategorizedLoss(categorizedLoss);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={{
|
||||
labels: Object.keys(categorizedLoss),
|
||||
datasets: [{ data: Object.values(categorizedLoss) }],
|
||||
}}
|
||||
opts={{
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `${context.formattedValue} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { LossByDefect, getLossByDefect } from '@/app/lib/data/dashboard';
|
||||
import { defectToLabel } from '@/app/lib/defect.enum';
|
||||
|
||||
import { BarChart } from './BarChart/BarChart';
|
||||
|
||||
export function LossByDefectChart() {
|
||||
const [lossByDefect, setLossByDefect] = useState<LossByDefect>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const lossByDefect = await getLossByDefect();
|
||||
setLossByDefect(lossByDefect);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={{
|
||||
labels: lossByDefect.map(({ _id }) => defectToLabel(_id)),
|
||||
datasets: [{ data: lossByDefect.map(({ weight }) => weight) }],
|
||||
}}
|
||||
opts={{
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => `${context.formattedValue} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
callback: (value: string | number) => `${value} lbs`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
title='Loss by Defect'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getLossByFarm } from '@/app/lib/data/dashboard';
|
||||
|
||||
import { CategorizedLossChart } from './CategorizedLossChart';
|
||||
|
||||
export function LossByFarmChart() {
|
||||
return (
|
||||
<CategorizedLossChart
|
||||
dataGetter={getLossByFarm}
|
||||
title='Aggregate Loss by Farm'
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getLossByProcessor } from '@/app/lib/data/dashboard';
|
||||
|
||||
import { CategorizedLossChart } from './CategorizedLossChart';
|
||||
|
||||
export function LossByProcessorChart() {
|
||||
return (
|
||||
<CategorizedLossChart
|
||||
dataGetter={getLossByProcessor}
|
||||
title='Aggregate Loss by Processor'
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
src/app/(authenticated)/dashboard/page.module.scss
Normal file
37
src/app/(authenticated)/dashboard/page.module.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
.page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h1 {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 1.5rem;
|
||||
row-gap: 2rem;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
.fullPanel {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
38
src/app/(authenticated)/dashboard/page.tsx
Normal file
38
src/app/(authenticated)/dashboard/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { AggregateBinWeightsChart } from './components/AggregateBinWeightsChart';
|
||||
import { LossByDefectChart } from './components/LossByDefectChart';
|
||||
import { LossByProcessorChart } from './components/LossByProcessorChart';
|
||||
import { LossByFarmChart } from './components/LossByFarmChart';
|
||||
import { CategorizedLossByOperatorChart } from './components/CategorizedLossByOperatorChart';
|
||||
|
||||
import styles from './page.module.scss';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<h1>LPD+S Dashboard</h1>
|
||||
<Link href="/dashboard/bins"><button>Bin Contents ></button></Link>
|
||||
</header>
|
||||
<div className={styles.panels}>
|
||||
<div className={styles.fullPanel}>
|
||||
<AggregateBinWeightsChart />
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<LossByDefectChart />
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<CategorizedLossByOperatorChart />
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<LossByProcessorChart />
|
||||
</div>
|
||||
<div className={styles.panel}>
|
||||
<LossByFarmChart />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
src/app/(authenticated)/layout.tsx
Normal file
11
src/app/(authenticated)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { SupplyChainProvider } from '../lib/context/SupplyChain/SupplyChainProvider';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SupplyChainProvider>
|
||||
{children}
|
||||
</SupplyChainProvider>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
107
src/app/globals.css
Normal file
107
src/app/globals.css
Normal file
@@ -0,0 +1,107 @@
|
||||
:root {
|
||||
--max-width: 1100px;
|
||||
--border-radius: 12px;
|
||||
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
|
||||
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
|
||||
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
--primary-glow: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
#16abff33 0deg,
|
||||
#0885ff33 55deg,
|
||||
#54d6ff33 120deg,
|
||||
#0071ff33 160deg,
|
||||
transparent 360deg
|
||||
);
|
||||
--secondary-glow: radial-gradient(
|
||||
rgba(255, 255, 255, 1),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
|
||||
--tile-start-rgb: 239, 245, 249;
|
||||
--tile-end-rgb: 228, 232, 233;
|
||||
--tile-border: conic-gradient(
|
||||
#00000080,
|
||||
#00000040,
|
||||
#00000030,
|
||||
#00000020,
|
||||
#00000010,
|
||||
#00000010,
|
||||
#00000080
|
||||
);
|
||||
|
||||
--callout-rgb: 238, 240, 241;
|
||||
--callout-border-rgb: 172, 175, 176;
|
||||
--card-rgb: 180, 185, 188;
|
||||
--card-border-rgb: 131, 134, 135;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
|
||||
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
|
||||
--secondary-glow: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0),
|
||||
rgba(1, 65, 255, 0.3)
|
||||
);
|
||||
|
||||
--tile-start-rgb: 2, 13, 46;
|
||||
--tile-end-rgb: 2, 5, 19;
|
||||
--tile-border: conic-gradient(
|
||||
#ffffff80,
|
||||
#ffffff40,
|
||||
#ffffff30,
|
||||
#ffffff20,
|
||||
#ffffff10,
|
||||
#ffffff10,
|
||||
#ffffff80
|
||||
);
|
||||
|
||||
--callout-rgb: 20, 20, 20;
|
||||
--callout-border-rgb: 108, 108, 108;
|
||||
--card-rgb: 100, 100, 100;
|
||||
--card-border-rgb: 200, 200, 200;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
23
src/app/layout.tsx
Normal file
23
src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className} suppressHydrationWarning={true}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
38
src/app/lib/barcode.ts
Normal file
38
src/app/lib/barcode.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
import { Product } from "./product.enum";
|
||||
import { dateToOrdinal } from './ordinalDate';
|
||||
import { ValueOf } from 'next/dist/shared/lib/constants';
|
||||
|
||||
export type Barcode = string;
|
||||
|
||||
export type ParsedBarcode = {
|
||||
product: Product;
|
||||
date: string;
|
||||
processor: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export const barcodeToProduct = (barcode: Barcode): ParsedBarcode => {
|
||||
const [ , productCode, date, processor, weight ] = barcode.match(/^(\d{5})(\d{5})(\d{2})(\d{4})$/) || [];
|
||||
const product = Object.entries(Product).find(([k, v]) => v === productCode);
|
||||
|
||||
if (!product) {
|
||||
throw new Error(`Invalid barcode: ${barcode}`);
|
||||
}
|
||||
|
||||
return {
|
||||
product: product[0] as Product,
|
||||
date: date,
|
||||
processor,
|
||||
weight: parseInt(weight) / 100,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateRandomBarcode = (): Barcode => {
|
||||
const product = faker.helpers.enumValue(Product);
|
||||
const date = dateToOrdinal(new Date(faker.date.recent({ days: 30 })));
|
||||
const processor = faker.number.int({ min: 11, max: 17 });
|
||||
const weight = faker.number.int({ min: 1000, max: 1999 });
|
||||
return `${product}${date}${processor}${weight}`;
|
||||
};
|
||||
19
src/app/lib/bin.enum.ts
Normal file
19
src/app/lib/bin.enum.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export enum Bin {
|
||||
LOSS,
|
||||
PROCESS,
|
||||
DONATE,
|
||||
SHOULDER_TAP,
|
||||
}
|
||||
|
||||
export const binToLabel = (bin: Bin) => {
|
||||
switch (bin) {
|
||||
case Bin.LOSS:
|
||||
return 'Loss';
|
||||
case Bin.PROCESS:
|
||||
return 'Process';
|
||||
case Bin.DONATE:
|
||||
return 'Donate';
|
||||
case Bin.SHOULDER_TAP:
|
||||
return 'Shoulder Tap';
|
||||
}
|
||||
};
|
||||
6
src/app/lib/context/SupplyChain/SupplyChainContext.ts
Normal file
6
src/app/lib/context/SupplyChain/SupplyChainContext.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
import { Farm } from '@/models/farm';
|
||||
import { Processor } from '@/models/processor';
|
||||
|
||||
export const SupplyChainContext = createContext<{ farms: Farm[], processors: Processor[] }>({ farms: [], processors: [] });
|
||||
13
src/app/lib/context/SupplyChain/SupplyChainProvider.tsx
Normal file
13
src/app/lib/context/SupplyChain/SupplyChainProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { SupplyChainContext } from './SupplyChainContext';
|
||||
import { useSupplyChainData } from './hooks';
|
||||
|
||||
export const SupplyChainProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||
const { farms, processors } = useSupplyChainData();
|
||||
return (
|
||||
<SupplyChainContext.Provider value={{ farms, processors }}>
|
||||
{children}
|
||||
</SupplyChainContext.Provider>
|
||||
);
|
||||
};
|
||||
22
src/app/lib/context/SupplyChain/hooks.ts
Normal file
22
src/app/lib/context/SupplyChain/hooks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Farm } from '@/models/farm';
|
||||
import { Processor } from '@/models/processor';
|
||||
import { getSupplyChainData } from '../../data/utils';
|
||||
|
||||
export const useSupplyChainData = () => {
|
||||
const [farms, setFarms] = useState<Farm[]>([]);
|
||||
const [processors, setProcessors] = useState<Processor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const { farms, processors } = await getSupplyChainData();
|
||||
setFarms(farms);
|
||||
setProcessors(processors);
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return { farms, processors };
|
||||
};
|
||||
32
src/app/lib/data/binned.ts
Normal file
32
src/app/lib/data/binned.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { StringSchemaDefinition } from 'mongoose';
|
||||
|
||||
import Binned, { Binned as BinItem } from '../../../models/binned';
|
||||
import { Bin } from '../bin.enum';
|
||||
import db from '../db/connection';
|
||||
|
||||
export type BinsContents = { [key in Bin]: BinItem[] };
|
||||
|
||||
export async function addToBin(bin: number, item: Omit<BinItem, 'bin'>) {
|
||||
await db();
|
||||
const binned = new Binned({ ...item, bin });
|
||||
await binned.save();
|
||||
}
|
||||
|
||||
export async function getBinsContents(): Promise<BinsContents> {
|
||||
await db();
|
||||
const bins = {} as BinsContents;
|
||||
const createdAt = new Date();
|
||||
createdAt.setUTCHours(0,0,0,0);
|
||||
|
||||
// Should be today, but lets do yesterday so there is more to see for demo purposes
|
||||
const binned = await Binned.find({ createdAt: { $gte: createdAt }});
|
||||
binned.forEach((item: BinItem) => bins[item.bin] = [...(bins[item.bin] ?? []), item]);
|
||||
return JSON.parse(JSON.stringify(bins));
|
||||
}
|
||||
|
||||
export async function switchBin(bin: number, id: StringSchemaDefinition) {
|
||||
await db();
|
||||
const binned = await Binned.findOneAndUpdate({ id }, { bin }, { new: true });
|
||||
}
|
||||
63
src/app/lib/data/dashboard.ts
Normal file
63
src/app/lib/data/dashboard.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use server';
|
||||
|
||||
import Binned, { Binned as BinItem, PopulatedBinned } from '../../../models/binned';
|
||||
import { Bin as BinEnum } from "../bin.enum";
|
||||
|
||||
import db from '../db/connection';
|
||||
|
||||
export type BinWeights = { [key in BinEnum]: number };
|
||||
export type CategorizedLoss = { [key: string]: number };
|
||||
export type LossByDefect = { _id: number, weight: number }[];
|
||||
export type CategorizedOperatorLoss = { [key: string]: BinWeights };
|
||||
|
||||
const binWeightTemplate = { [BinEnum.LOSS]: 0, [BinEnum.PROCESS]: 0, [BinEnum.DONATE]: 0, [BinEnum.SHOULDER_TAP]: 0 };
|
||||
|
||||
export async function getAggregateBinWeights(): Promise<BinWeights> {
|
||||
await db();
|
||||
const binned = await Binned.find();
|
||||
return binned.reduce((acc: BinWeights, cur: BinItem) => ({
|
||||
...acc,
|
||||
[cur.bin]: acc[cur.bin] + cur.weight,
|
||||
}), binWeightTemplate);
|
||||
}
|
||||
|
||||
export async function getLossByDefect(): Promise<LossByDefect> {
|
||||
await db();
|
||||
return Binned.aggregate([
|
||||
{ $group: { _id: "$reason", weight: { $sum: "$weight" } } },
|
||||
]);
|
||||
};
|
||||
|
||||
export async function getLossByFarm(): Promise<CategorizedLoss> {
|
||||
await db();
|
||||
const binned = await Binned.find().populate<PopulatedBinned>('farm');
|
||||
return binned.reduce((acc: { [key: string]: number }, cur) => {
|
||||
return cur.farm ? {
|
||||
...acc,
|
||||
[cur.farm.name]: (acc[cur.farm.name] ?? 0) + cur.weight,
|
||||
} : acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export async function getLossCategoriesByOperator(): Promise<CategorizedOperatorLoss> {
|
||||
await db();
|
||||
const binned = await Binned.find();
|
||||
return binned.reduce((acc: { [key: string]: BinWeights }, cur: BinItem) => {
|
||||
const existingBinWeight = (acc[cur.operator] || binWeightTemplate)[cur.bin];
|
||||
return {
|
||||
...acc,
|
||||
[cur.operator]: { ...acc[cur.operator] || binWeightTemplate, [cur.bin]: existingBinWeight + cur.weight }
|
||||
}
|
||||
}, {});
|
||||
};
|
||||
|
||||
export async function getLossByProcessor(): Promise<CategorizedLoss> {
|
||||
await db();
|
||||
const binned = await Binned.find().populate<PopulatedBinned>('processor');
|
||||
return binned.reduce((acc: { [key: string]: number }, cur) => {
|
||||
return cur.processor ? {
|
||||
...acc,
|
||||
[cur.processor?.name]: (acc[cur.processor.name] || 0) + cur.weight,
|
||||
} : acc;
|
||||
}, {});
|
||||
};
|
||||
36
src/app/lib/data/utils.ts
Normal file
36
src/app/lib/data/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import Farms, { Farm } from '@/models/farm';
|
||||
import Processors, { Processor } from '@/models/processor';
|
||||
|
||||
import db from '../db/connection';
|
||||
|
||||
export async function getFarms() {
|
||||
await db();
|
||||
const farms = await Farms.find();
|
||||
return JSON.parse(JSON.stringify(farms));
|
||||
}
|
||||
|
||||
export async function getProcessors() {
|
||||
await db();
|
||||
const processor = await Processors.find();
|
||||
return JSON.parse(JSON.stringify(processor));
|
||||
}
|
||||
|
||||
export async function getSupplyChainData() {
|
||||
await db();
|
||||
const [farms, processors] = await Promise.all([getFarms(), getProcessors()]);
|
||||
return { farms, processors };
|
||||
}
|
||||
|
||||
export async function addFarm(farm: Farm) {
|
||||
await db();
|
||||
const newFarm = new Farms(farm);
|
||||
await newFarm.save();
|
||||
}
|
||||
|
||||
export async function addProcessor(processor: Processor) {
|
||||
await db();
|
||||
const newProcessor = new Processors(processor);
|
||||
await newProcessor.save();
|
||||
}
|
||||
53
src/app/lib/db/connection.ts
Normal file
53
src/app/lib/db/connection.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
'use server';
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
import { seedDb } from './seedDb';
|
||||
|
||||
declare global {
|
||||
var mongoose: any;
|
||||
}
|
||||
|
||||
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017/bopeep';
|
||||
|
||||
if (!MONGO_URL) {
|
||||
throw new Error(
|
||||
"Please define the MONGO_URL environment variable inside .env.local",
|
||||
);
|
||||
}
|
||||
|
||||
let cached = global.mongoose;
|
||||
|
||||
if (!cached) {
|
||||
cached = global.mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
async function connection() {
|
||||
if (cached.conn) {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
};
|
||||
|
||||
cached.promise = mongoose.connect(MONGO_URL, opts);
|
||||
}
|
||||
|
||||
try {
|
||||
cached.conn = await cached.promise;
|
||||
console.log('Connected to Mongo DB');
|
||||
} catch (error) {
|
||||
cached.promise = null;
|
||||
console.error('Could not connect to Mongo DB', error);
|
||||
}
|
||||
|
||||
await seedDb()
|
||||
.then(() => console.log('Database seeded'))
|
||||
.catch((error) => console.error('Error seeding database', error));
|
||||
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
export default connection;
|
||||
100
src/app/lib/db/placeholder-data.js
Normal file
100
src/app/lib/db/placeholder-data.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Bin, binToLabel } from '../bin.enum';
|
||||
|
||||
export const processors = [
|
||||
{
|
||||
barcode_id: '11',
|
||||
name: 'Harvey\'s Hot Dogs',
|
||||
locality: 'Tewksbury, MA'
|
||||
},
|
||||
{
|
||||
barcode_id: '12',
|
||||
name: 'Jess\'s Fancy Foods',
|
||||
locality: 'Belfast, ME'
|
||||
},
|
||||
{
|
||||
barcode_id: '13',
|
||||
name: 'We Grind Meats, LLC',
|
||||
locality: 'Greenwich, CT'
|
||||
},
|
||||
{
|
||||
barcode_id: '14',
|
||||
name: 'Pastures to Patties, Inc.',
|
||||
locality: 'Warren, VT'
|
||||
},
|
||||
{
|
||||
barcode_id: '15',
|
||||
name: 'Palumbo Pork Products',
|
||||
locality: 'New Boston, NH'
|
||||
},
|
||||
{
|
||||
barcode_id: '16',
|
||||
name: 'Not that Tyson\'s Chicken',
|
||||
locality: 'North Adams, MA'
|
||||
},
|
||||
{
|
||||
barcode_id: '17',
|
||||
name: 'We Process the Beef',
|
||||
locality: 'Gardner, MA'
|
||||
},
|
||||
];
|
||||
|
||||
export const farms = [
|
||||
{
|
||||
name: 'John\'s Farm',
|
||||
locality: 'New Boston, NH',
|
||||
farmer: 'Farmer John'
|
||||
},
|
||||
{
|
||||
name: 'Jane\'s Farm',
|
||||
locality: 'Warren, VT',
|
||||
farmer: 'Farmer Jane'
|
||||
},
|
||||
{
|
||||
name: 'Joe\'s Farm',
|
||||
locality: 'Belfast, ME',
|
||||
farmer: 'Farmer Joe'
|
||||
},
|
||||
{
|
||||
name: 'Jack\'s Farm',
|
||||
locality: 'Tewksbury, MA',
|
||||
farmer: 'Farmer Jack'
|
||||
},
|
||||
{
|
||||
name: 'Jill\'s Farm',
|
||||
locality: 'Gardner, MA',
|
||||
farmer: 'Farmer Jill'
|
||||
},
|
||||
{
|
||||
name: 'Jim\'s Farm',
|
||||
locality: 'North Adams, MA',
|
||||
farmer: 'Farmer Jim'
|
||||
},
|
||||
{
|
||||
name: 'Jeff\'s Farm',
|
||||
locality: 'Greenwich, CT',
|
||||
farmer: 'Farmer Jeff'
|
||||
},
|
||||
];
|
||||
|
||||
export const bins = [
|
||||
{
|
||||
key: Bin.LOSS,
|
||||
label: binToLabel(Bin.LOSS),
|
||||
active: true
|
||||
},
|
||||
{
|
||||
key: Bin.PROCESS,
|
||||
label: binToLabel(Bin.PROCESS),
|
||||
active: true
|
||||
},
|
||||
{
|
||||
key: Bin.DONATE,
|
||||
label: binToLabel(Bin.DONATE),
|
||||
active: true
|
||||
},
|
||||
{
|
||||
key: Bin.SHOULDER_TAP,
|
||||
label: binToLabel(Bin.SHOULDER_TAP),
|
||||
active: true
|
||||
},
|
||||
];
|
||||
17
src/app/lib/db/seedDb.ts
Normal file
17
src/app/lib/db/seedDb.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import Farm from '../../../models/farm';
|
||||
import Processor from '../../../models/processor';
|
||||
|
||||
import { bins, farms, processors } from './placeholder-data';
|
||||
|
||||
export const seedDb = async (overwrite = false) => {
|
||||
if (overwrite || await Farm.countDocuments() === 0) {
|
||||
await Farm.deleteMany({});
|
||||
await Farm.insertMany(farms);
|
||||
}
|
||||
if (overwrite || await Processor.countDocuments() === 0) {
|
||||
await Processor.deleteMany({});
|
||||
await Processor.insertMany(processors);
|
||||
}
|
||||
};
|
||||
28
src/app/lib/defect.enum.ts
Normal file
28
src/app/lib/defect.enum.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export enum Defect {
|
||||
BROKEN_PACKAGING,
|
||||
MISSING_BARCDODE,
|
||||
MISSING_LABEL,
|
||||
FREEZER_BURN,
|
||||
DISCOLORATION,
|
||||
ODOR,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
export const defectToLabel = (defect: Defect): string => {
|
||||
switch (defect) {
|
||||
case Defect.BROKEN_PACKAGING:
|
||||
return 'Broken Packaging';
|
||||
case Defect.MISSING_BARCDODE:
|
||||
return 'Missing Barcode';
|
||||
case Defect.MISSING_LABEL:
|
||||
return 'Missing Label';
|
||||
case Defect.FREEZER_BURN:
|
||||
return 'Freezer Burn';
|
||||
case Defect.DISCOLORATION:
|
||||
return 'Discoloration';
|
||||
case Defect.ODOR:
|
||||
return 'Odor';
|
||||
case Defect.OTHER:
|
||||
return 'Other';
|
||||
}
|
||||
};
|
||||
5
src/app/lib/getProcessorByBarcodeId.ts
Normal file
5
src/app/lib/getProcessorByBarcodeId.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Processor } from "@/models/processor";
|
||||
|
||||
export const getProcessorIdByBarcodeId = (processorId: string, processors: Processor[]) => {
|
||||
return processors.find((processor) => processor.barcode_id === processorId) || null;
|
||||
};
|
||||
15
src/app/lib/ordinalDate.ts
Normal file
15
src/app/lib/ordinalDate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
export const ordinalToDate = (ordinal: number | string): Date => {
|
||||
const year = parseInt(`20${ordinal.toString().substring(0, 2)}`);
|
||||
const day = parseInt(ordinal.toString().substring(2));
|
||||
const date = new Date(year, 0);
|
||||
date.setDate(day);
|
||||
return date;
|
||||
};
|
||||
|
||||
export const dateToOrdinal = (date: Date): number => {
|
||||
const start = new Date(date.getUTCFullYear(), 0, 0);
|
||||
const diff = date.getTime() - start.getTime();
|
||||
const day = `00${(Math.floor(diff / (1000 * 60 * 60 * 24)) + 1)}`;
|
||||
return parseInt(`${(`${date.getUTCFullYear()}`).substring(2)}${day.substring(day.length - 3)}`);
|
||||
};
|
||||
63
src/app/lib/product.enum.ts
Normal file
63
src/app/lib/product.enum.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export enum Product {
|
||||
RIBEYE = '01010',
|
||||
NYSTRIP = '01020',
|
||||
FILET = '01030',
|
||||
PORTERHOUSE = '01040',
|
||||
TENDERLOIN = '01050',
|
||||
TENDERLOIN_TIPS = '01060',
|
||||
TOP_SIRLOIN = '01070',
|
||||
TRI_TIP = '01080',
|
||||
FLANK = '01090',
|
||||
SKIRT = '01100',
|
||||
HANGER = '01110',
|
||||
FLAT_IRON = '01120',
|
||||
BRISKET = '01130',
|
||||
SHORT_RIBS = '01140',
|
||||
CHUCK_ROAST = '01150',
|
||||
SHORT_LOIN = '01160',
|
||||
SIRLOIN_TIP = '01170',
|
||||
ROUND = '01180',
|
||||
SHANK = '01190',
|
||||
GROUND_BEEF = '01200',
|
||||
BEEF_STEW = '01210',
|
||||
BEEF_BONES = '01220',
|
||||
BEEF_LIVER = '01230',
|
||||
BEEF_HEART = '01240',
|
||||
BEEF_KIDNEY = '01250',
|
||||
BEEF_TONGUE = '01260',
|
||||
BEEF_OXTAIL = '01270',
|
||||
MEATBALLS = '01280',
|
||||
CHICKEN_BREAST = '02010',
|
||||
CHICKEN_THIGHS = '02020',
|
||||
CHICKEN_LEGS = '02030',
|
||||
CHICKEN_WINGS = '02040',
|
||||
WHOLE_CHICKEN = '02050',
|
||||
WHOLE_DUCK = '02060',
|
||||
PORK_CHOPS = '03010',
|
||||
PORK_TENDERLOIN = '03020',
|
||||
PORK_ROAST = '03030',
|
||||
PORK_RIBS = '03040',
|
||||
PORK_BELLY = '03050',
|
||||
PORK_BUTT = '03060',
|
||||
PORK_SHOULDER = '03070',
|
||||
PORK_HAM = '03080',
|
||||
PORK_HOCK = '03090',
|
||||
BACON = '03100',
|
||||
PORK_SAUSAGE = '03110',
|
||||
TURKEY_SAUSAGE = '03120',
|
||||
CHICKEN_SAUSAGE = '03130',
|
||||
HOT_DOGS = '03140',
|
||||
HAMBURGER_PATTIES = '03150',
|
||||
GROUND_PORK = '03160',
|
||||
GROUND_CHICKEN = '03170',
|
||||
GROUND_TURKEY = '03180',
|
||||
GROUND_LAMB = '03190',
|
||||
GROUND_BISON = '03200',
|
||||
VENISON = '03210',
|
||||
ELK = '03220',
|
||||
LAMB = '03230',
|
||||
BISON = '03240',
|
||||
TURKEY = '03250',
|
||||
TURKEY_BREAST = '03260',
|
||||
TURKEY_LEGS = '03270',
|
||||
}
|
||||
28
src/app/page.module.css
Normal file
28
src/app/page.module.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.main {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.screenWrap {
|
||||
flex: 0 1 85%;
|
||||
}
|
||||
|
||||
.screen {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
min-width: 20%;
|
||||
}
|
||||
|
||||
.buttonLink {
|
||||
flex: 1 0 15%;
|
||||
align-self: flex-end
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 1.3em;
|
||||
padding: 2em 1em;
|
||||
align-self: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
15
src/app/page.tsx
Normal file
15
src/app/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import styles from './page.module.css';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.screenWrap}>
|
||||
<img src="/screen.jpg" alt="Walden Screen" width={1016} height={577} className={styles.screen} />
|
||||
</div>
|
||||
<Link href="/binner" className={styles.buttonLink}><button className={styles.button}>LPD+S</button></Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
45
src/models/binned.ts
Normal file
45
src/models/binned.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose, { Document, Schema, StringSchemaDefinition, Types } from 'mongoose';
|
||||
|
||||
import { Defect } from "@/app/lib/defect.enum";
|
||||
import { Product } from "@/app/lib/product.enum";
|
||||
import { Bin } from '@/app/lib/bin.enum';
|
||||
|
||||
import { Farm } from './farm';
|
||||
import { Processor } from './processor';
|
||||
|
||||
export interface Binned {
|
||||
bin: Bin;
|
||||
date: string;
|
||||
farm?: StringSchemaDefinition;
|
||||
operator: string;
|
||||
processor: StringSchemaDefinition;
|
||||
product: Product;
|
||||
reason?: Defect;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export type BinnedDocument = Binned & Document;
|
||||
|
||||
export type PopulatedBinned = Omit<Binned, 'farm' | 'processor'> & {
|
||||
farm: Farm;
|
||||
processor: Processor;
|
||||
};
|
||||
|
||||
const BinnedSchema = new Schema<BinnedDocument>(
|
||||
{
|
||||
bin: { type: Number, enum: Bin, required: true },
|
||||
farm: { type: Types.ObjectId, ref: 'Farm' },
|
||||
operator: { type: String, required: true },
|
||||
date: { type: String, required: true },
|
||||
processor: { type: Types.ObjectId, ref: 'Processor' },
|
||||
product: { type: String, enum: Object.keys(Product) },
|
||||
reason: { type: Number, enum: Defect },
|
||||
weight: { type: Number }
|
||||
},
|
||||
{
|
||||
minimize: true,
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
export default mongoose.models?.Binned || mongoose.model<BinnedDocument>('Binned', BinnedSchema);
|
||||
21
src/models/farm.ts
Normal file
21
src/models/farm.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import mongoose, { Document, Schema } from 'mongoose';
|
||||
|
||||
export interface Farm extends Document {
|
||||
farmer?: string;
|
||||
locality: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FarmSchema = new Schema<Farm>(
|
||||
{
|
||||
farmer: { type: String },
|
||||
locality: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
{
|
||||
minimize: true,
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
export default mongoose.models?.Farm || mongoose.model<Farm>('Farm', FarmSchema);
|
||||
21
src/models/processor.ts
Normal file
21
src/models/processor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import mongoose, { Schema } from 'mongoose';
|
||||
|
||||
export interface Processor extends Document {
|
||||
barcode_id: string;
|
||||
locality: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ProcessorSchema = new Schema<Processor>(
|
||||
{
|
||||
barcode_id: { type: String, required: true },
|
||||
locality: { type: String, required: true },
|
||||
name: { type: String, required: true },
|
||||
},
|
||||
{
|
||||
minimize: true,
|
||||
timestamps: true,
|
||||
},
|
||||
);
|
||||
|
||||
export default mongoose.models?.Processor || mongoose.model<Processor>('Processor', ProcessorSchema);
|
||||
Reference in New Issue
Block a user