Initial commit

Load this up somewhere where I can setup CI/CD
This commit is contained in:
2024-01-24 13:05:19 -05:00
commit 7119957c9e
458 changed files with 10153 additions and 0 deletions

View File

@@ -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>
);
}

View File

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

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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">&times;</button>
<span><strong>{Bin[bin]} Bin</strong></span>
</header>
<DetailsForm />
</div>
);
}

View File

@@ -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));

View File

@@ -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>
);
}

View File

@@ -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]);
};

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View 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);

View 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>
);
};

View 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]);
};

View 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>
);
}

View 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;
}

View 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>
);
}

View 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);
}
}
}

View 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>&lt; 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>
);
}

View File

@@ -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'
/>
);
}

View File

@@ -0,0 +1,3 @@
.title {
margin-bottom: 1rem;
}

View File

@@ -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>
);
}

View File

@@ -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'
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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'
/>
);
}

View File

@@ -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'
/>
);
}

View File

@@ -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'
/>
);
}

View 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%;
}

View 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 &gt;</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>
);
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

107
src/app/globals.css Normal file
View 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
View 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
View 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
View 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';
}
};

View 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: [] });

View 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>
);
};

View 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 };
};

View 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 });
}

View 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
View 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();
}

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

View 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
View 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);
}
};

View 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';
}
};

View 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;
};

View 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)}`);
};

View 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
View 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
View 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
View 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
View 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
View 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);