You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
joint_exc/frontend/src/pages/StockPage.tsx

638 lines
23 KiB

/**
* テストページコンポーネント
* 白紙の状態で表示されるテスト用のページ
*/
import React, { useState, useEffect } from 'react';
import { stockApi, stuffApi } from '../services/api';
import { Stock, StockUpdateRequest, Stuff } from '../types/types';
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from "@mui/material";
import {
Container,
Typography,
Tooltip,
Fab,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
Checkbox,
FormControlLabel,
FormGroup,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material';
import '../App.css';
import { GENERAL_ERRORS, STOCK_ERRORS } from '../constants/errorMessages';
import { Add as AddIcon, KeyboardArrowDown as ArrowDownIcon, KeyboardArrowUp as ArrowUpIcon, Inventory as InventoryIcon } from '@mui/icons-material';
import DatePicker, { registerLocale } from 'react-datepicker';
import { ja } from 'date-fns/locale/ja'; // date-fnsの日本語ロケールをインポート
/*import DatePicker, { registerLocale } from 'react-datepicker';
import { ja } from 'date-fns/locale/ja'; // date-fnsの日本語ロケールをインポート*/
import { useMessage } from '../components/MessageContext';
import BuyExpDateSelect from '../components/BuyExpDateSelect';
import { STOCK_MESSAGES } from '../constants/normalMessages';
import DeleteStuffDialog from '../components/DeleteStuffDialog';
import AddStuffAmountDialog from '../components/AddStuffAmountDialog';
/*// 日付をyyyy-MM-dd形式で返す関数
const formatDateLocal = (date: Date) => {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
};*/
// 新規在庫の初期状態
const EMPTY_STOCK: Omit<Stock, 'stockId' | 'stuffId'> & { stuffId: number | null } & { newAddition: boolean } = {
stuffId: null,
stuffName: '',
amount: 1,
buyAmount: 1,
price: 0,
shop: '',
lastUpdate: '',
buyDate: new Date().toISOString(),
expDate: '',
category: '',
newAddition: false, // 材料を新規作成するか否か
// shop '',
}
// 日本語ロケールを登録
//registerLocale('ja', ja);
const StockPage: React.FC = () => {
const [stocks, setStocks] = useState<Stock[]>();
// セル選択の表示状態
const [selectedRow, setSelectedRow] = useState<Stock | null>(null);
// 追加ダイアログボックスの表示状態
const [openAddDialog, setOpenAddDialog] = useState(false);
// 編集ダイアロボックスの表示状態
const [openEditDialog, setOpenEditDialog] = useState(false);
// 削除メッセージダイアログの表示状態
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
// 在庫追加に使う状態
const [newStock, setNewStock] = useState(EMPTY_STOCK);
const [stuffs, setStuffs] = useState<Stuff[]>([]);
// 在庫の編集状態
const [editStock, setEditStock] = useState<Stock | null>(null);
const { showErrorMessage, showWarningMessage, showSuccessMessage } = useMessage();
// カテゴリ名一覧
const CATEGORY_NAMES = [
"乳製品",
"魚・肉",
"野菜",
"調味料",
"その他",
];
const [openCategory, setOpenCategory] = useState(Object.fromEntries(
CATEGORY_NAMES.map(category => [category, true])
));
// コンポーネントマウント時にタスク一覧を取得
useEffect(() => {
fetchStocks();
}, []);
/**
* APIから在庫一覧を取得する関数
* 取得したタスクをstate(tasks)に設定
*/
const fetchStocks = async () => {
try {
const stocks = await stockApi.getStocks();
console.log('Stocks=', stocks)
setStocks(stocks);
} catch (error) {
console.error(`${STOCK_ERRORS.FETCH_FAILED}:`, error);
showErrorMessage(STOCK_ERRORS.FETCH_FAILED);
}
};
/**
* 在庫リストに新規食材を作成するハンドラー
*/
const handleCreateStock = async () => {
try {
// カテゴリ、材料名、数量などのチェックはコンポーネント側で行われる
if (newStock.price === null || isNaN(newStock.price)) {
showErrorMessage(GENERAL_ERRORS.INVALID_PRICE);
return;
}
if (newStock.buyAmount !== null && isNaN(newStock.buyAmount)) {
newStock.buyAmount = null;
}
newStock.shop = newStock.shop || null;
console.log(newStock)
// 購入日と消費・賞味期限の整合性チェック
if (newStock.expDate !== null) {
const buy = new Date(newStock.buyDate);
const exp = new Date(newStock.expDate);
// 時間をリセットして純粋な“日付のみ”のタイムスタンプを取得
const buyDateOnly = new Date(buy);
buyDateOnly.setHours(0, 0, 0, 0);
const expDateOnly = new Date(exp);
expDateOnly.setHours(0, 0, 0, 0);
// console.log("新規作成buy:", buy);
// console.log("新規作成exp:", exp);
// console.log("新規作成buyDateOnly:", buyDateOnly);
// console.log("新規作成expDateOnly:", expDateOnly);
if (buyDateOnly.getTime() > expDateOnly.getTime()) {
// alert("購入日は消費・賞味期限より前の日付を設定してください。");
showErrorMessage('購入日は消費・賞味期限より前の日付を設定してください.');
return;
}
}
const today = new Date().toISOString().substring(0, 10);
const updatedStock = { ...newStock, lastUpdate: today }; // lastUpdate に today を設定
console.log("送信するデータ:", updatedStock); // 送信前のデータを確認
await stockApi.addStock(updatedStock); // 修正したオブジェクトを API に送信
showSuccessMessage(STOCK_MESSAGES.CREATE_OK);
// await stockApi.addStock(newStock);
setOpenAddDialog(false); // ダイアログを閉じる
setNewStock(EMPTY_STOCK); // 入力内容をリセット
fetchStocks(); // 作成後のタスク一覧を再取得
} catch (error) {
console.error(`${STOCK_ERRORS.CREATE_FAILED}:`, error);
showErrorMessage(STOCK_ERRORS.CREATE_FAILED);
}
};
/**
* 在庫リストを編集するハンドラー
*/
const handleUpdateStock = async (request: StockUpdateRequest) => {
try {
await stockApi.updateStock(request);
fetchStocks(); // 削除後の買うもの一覧を再取得
// showSuccessMessage(STOCK_MESSAGES.UPDATE_OK);
} catch (error) {
console.error(`${STOCK_ERRORS.UPDATE_FAILED}:`, error);
// showErrorMessage(STOCK_ERRORS.UPDATE_FAILED);
}
};
/**
* 在庫を削除するハンドラー
* 指定されたIDのタスクをAPIを通じて削除
*/
const handleDeleteStock = async (stockId: number) => {
try {
await stockApi.deleteStock(stockId);
showSuccessMessage(STOCK_MESSAGES.DELETE_OK);
fetchStocks(); // 削除後の買うもの一覧を再取得
} catch (error) {
console.error(`${STOCK_ERRORS.DELETE_FAILED}:`, error);
showErrorMessage(STOCK_ERRORS.DELETE_FAILED);
}
};
/**
* カテゴリー選択
*/
const onChangeCategory = async (category: string) => {
setNewStock({ ...newStock, category })
const result = await stuffApi.getStuffs(category)
setStuffs(result)
}
/**
* 文字列(ISO 8601形式)をyyyy/MM/ddに変換する関数
*/
const formatDate = (isoString: string): string => {
const date = new Date(isoString);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // 月は0始まりなので+1
const day = date.getDate().toString().padStart(2, "0");
return `${year}/${month}/${day}`;
};
/* Date型をyyyy/MM/ddに変換する関数
const formatDate = (date: Date): string => {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // 月は0始まりなので+1
const day = date.getDate().toString().padStart(2, "0");
return `${year}/${month}/${day}`;
};
*/
/**
* セルを選択して編集画面
*/
const handleRowClick = (stock: Stock) => {
setSelectedRow(stock); // 行選択
setEditStock({ ...stock }); // 編集対象にセット
setOpenEditDialog(true); // 編集ダイアログを開く
};
// 変更を適用. 数量に0を入力したとき、削除ダイアログに飛ぶ機能を追加
const handleApplyChanges = async () => {
if (!editStock) return;
const { stockId, amount, buyAmount, price, shop, buyDate, expDate, lastUpdate } = editStock;
if (expDate !== null) {
// 購入日が消費・賞味期限より未来の場合はエラー表示
const buy = new Date(buyDate);
const exp = new Date(expDate);
// 時間をリセットして純粋な“日付のみ”のタイムスタンプを取得
const buyDateOnly = new Date(buy);
buyDateOnly.setHours(0, 0, 0, 0);
const expDateOnly = new Date(exp);
expDateOnly.setHours(0, 0, 0, 0);
// console.log("編集buy:", buy);
// console.log("編集exp:", exp);
// console.log("編集buyDateOnly:", buyDateOnly);
// console.log("編集expDateOnly:", expDateOnly);
if (buy > exp) {
// alert("購入日は消費・賞味期限より前の日付を設定してください。");
showErrorMessage('購入日は消費・賞味期限より前の日付を設定してください.');
return;
}
}
try {
// Number型に変換した変数を用意
const numericAmount = Number(amount);
const numericBuyAmount = Number(buyAmount);
const numericPrice = Number(price);
if (numericAmount === 0) {
// 数量が 0 の場合は削除処理へ誘導
// setIsEditOpen(false); // 編集ダイアログを閉じる
setSelectedRow(editStock); // 削除対象をセット
setOpenDeleteDialog(true); // 削除ダイアログを開く
return;
}
if (!numericAmount /* || !numericBuyAmount */) {
showErrorMessage(GENERAL_ERRORS.INVALID_AMOUNT);
return;
}
// if (!numericPrice) {
// showErrorMessage(GENERAL_ERRORS.INVALID_PRICE);
// return;
// }
const lastUpdate = new Date().toISOString().substring(0, 10);
const updateRequest = {
stockId,
amount,
buyAmount,
price,
shop,
buyDate,
expDate,
lastUpdate,
}
console.log('updateRequest:', updateRequest);
await handleUpdateStock(updateRequest);
console.log(STOCK_MESSAGES.UPDATE_OK)
showSuccessMessage(STOCK_MESSAGES.UPDATE_OK);
setSelectedRow(editStock); // 更新後に選択行を反映
fetchStocks(); // 最新データを取得
setSelectedRow(null); // 選択解除
} catch (error) {
console.error(`${STOCK_ERRORS.UPDATE_FAILED}:`, error);
showErrorMessage(STOCK_ERRORS.UPDATE_FAILED);
}
setOpenEditDialog(false); // 編集ダイアログを閉じる
};
// ダイアログを開く際に `selectedRow` の値を `editStock` にコピー
useEffect(() => {
if (selectedRow) {
setEditStock({ ...selectedRow });
}
}, [selectedRow]); // `selectedRow` が変更されたら `editStock` に反映
// テキストフィールドの変更を検知
// 負の値を入力できないように書き換え
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
// 数値項目に対して負の値をブロック
const numericFields = ['amount', 'buyAmount', 'price'];
const numericValue = Number(value);
const isNumericField = numericFields.includes(name);
if (isNumericField && numericValue < 0) {
return; // 無視して更新しない
}
if (editStock) {
setEditStock({
...editStock,
[name]: value,
});
}
};
/** 編集ダイアログを閉じる */
const handleCloseEdit = () => {
setOpenEditDialog(false);
setSelectedRow(null);
};
/** 削除ボタンを押したときにダイアログを開く */
// 現在の仕様では、未選択状態で削除ダイアログを開くことはないのでこの処理は不要
// const handleOpenDelete = () => {
// if (selectedRow) {
// setIsDeleteOpen(true);
// } else {
// // showWarningMessage("削除する食材を選択してください。");
// showErrorMessage('削除する食材を選択してください.');
// }
// };
/** テーブルを表示する関数 */
const StockTable = (stocks: Stock[], categories: string[]) => {
const filteredStocks = stocks.filter(stock => categories.includes(stock.category));
return (
!filteredStocks.length
? <Typography></Typography>
: <>
<TableContainer component={Paper}>
<Table size="small">
<TableHead sx={{ backgroundColor: "#ebcba2", color: "#333" }}>
<TableRow>
<TableCell align="center" sx={{ width: '40%', fontSize: '16px' }}></TableCell>
<TableCell align="center" sx={{ width: '20%', fontSize: '16px' }}></TableCell>
<TableCell align="center" sx={{ width: '40%', fontSize: '16px' }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredStocks.map(stock => {
let daysLeft = null;
if (stock.expDate !== null) {
const today = new Date();
const expDate = new Date(stock.expDate);
// 時間をリセットして純粋な“日付のみ”のタイムスタンプを取得
const todayDateOnly = new Date(today);
todayDateOnly.setHours(0, 0, 0, 0);
const expDateOnly = new Date(expDate);
expDateOnly.setHours(0, 0, 0, 0);
const timeDiff = expDateOnly.getTime() - todayDateOnly.getTime();
daysLeft = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
// console.log("テーブルtoday:", today);
// console.log("テーブルexp:", expDate);
// console.log("テーブルtodayDateOnly:", todayDateOnly);
// console.log("テーブルexpDateOnly:", expDateOnly);
// console.log("日数差:", daysLeft);
}
return (
<TableRow
key={stock.stockId}
onClick={() => handleRowClick(stock)}
style={{ backgroundColor: selectedRow?.stockId === stock.stockId ? "yellow" : "white", cursor: "pointer" }}
>
<TableCell align="center" sx={{ width: '40%', fontSize: '16px' }}>{stock.stuffName}</TableCell>
<TableCell align="center" sx={{ width: '20%', fontSize: '16px' }}>{stock.amount}</TableCell>
<TableCell align="center" sx={{ width: '40%', fontSize: '16px' }}
style={daysLeft !== null && daysLeft <= 3 ? { color: "red", fontWeight: "bold" } : {}}
>
{stock.expDate && formatDate(stock.expDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
{/* 編集ダイアログ */}
<Dialog open={openEditDialog} onClose={handleCloseEdit} fullWidth maxWidth="sm">
<DialogTitle>
<Typography variant="h5" >
</Typography>
</DialogTitle>
<DialogContent>
{editStock && (
<>
{/* 材料の詳細 */}
<Typography variant="h4">{editStock.stuffName}</Typography>
{/* 現在の数量フィールド */}
<TextField
label="現在の数量"
margin="normal"
name="amount"
type="number"
fullWidth
className="numberField"
value={editStock.amount}
onChange={handleChange}
inputProps={{ min: 0 }}
onKeyDown={(e) => {
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
e.preventDefault();
}
}}
/>
{/* 購入時数量フィールド */}
<TextField
label="購入時数量"
margin="normal"
name="buyAmount"
type="number"
fullWidth
value={editStock.buyAmount}
onChange={handleChange}
inputProps={{ min: 0 }}
onKeyDown={(e) => {
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
e.preventDefault();
}
}}
/>
{/* 購入価格フィールド */}
<TextField
label="購入価格"
margin="normal"
name="price"
type="number"
fullWidth
value={editStock.price}
onChange={handleChange}
inputProps={{ min: 0 }}
onKeyDown={(e) => {
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
e.preventDefault();
}
}}
/>
{/* 購入店舗フィールド */}
<TextField
label="購入店舗"
fullWidth
margin="normal"
name="shop"
type="text"
value={editStock.shop}
onChange={handleChange}
/>
{/* 購入日・賞味期限入力 */}
<BuyExpDateSelect newStock={editStock} setNewStock={({ buyDate, expDate }) => setEditStock({ ...editStock, buyDate, expDate })} />
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => { setOpenEditDialog(false); setSelectedRow(null); }}>
</Button>
<Button variant="contained" color="success" onClick={handleApplyChanges}>
</Button>
<Button variant="contained" color="error" onClick={() => setOpenDeleteDialog(true)}></Button>
</DialogActions>
</Dialog>
{/* 削除ダイアログ */}
{selectedRow &&
<DeleteStuffDialog openDialog={openDeleteDialog} setOpenDialog={setOpenDeleteDialog}
stuffName={selectedRow.stuffName}
onSubmit={() => {
handleDeleteStock(selectedRow.stockId);
setSelectedRow(null);
setOpenEditDialog(false); // 編集画面から飛んだ場合に備え、編集画面も閉じる
}}
/>}
</>
);
};
return (
<div className="mainContainer">
<div className="mainTitle">
<InventoryIcon sx={{ mr: "0.5em" }} />
</div>
{/* 新規タスク作成ダイアログ */}
<AddStuffAmountDialog title="在庫に食材を追加"
openDialog={openAddDialog} setOpenDialog={setOpenAddDialog} newItem={newStock} setNewItem={(item) => setNewStock({ ...newStock, ...item })}
onSubmit={handleCreateStock}
>
{/* 購入時数量入力フィールド */}
<TextField
margin="dense"
label="購入時数量"
value={newStock.buyAmount}
className="numberField"
onChange={(e) => {
const value = e.target.value;
const parsedValue = parseInt(value, 10); // 数値に変換
if (isNaN(parsedValue) || parsedValue >= 1) { // 入力欄をいったん空欄にできるようにする,ただし空欄でない場合は1以上のみOK
setNewStock({ ...newStock, buyAmount: parsedValue }); // number型で保存
}
}}
// sx={{ width: "50%" }}
type="number"
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} // ここで整数のみ許可
/>
{/* 購入価格入力フィールド */}
<TextField
margin="dense"
label="購入価格"
fullWidth
value={newStock.price}
onChange={(e) => {
const value = e.target.value;
const parsedValue = parseInt(value, 10); // 数値に変換
if (isNaN(parsedValue) || parsedValue >= 0) { // 入力欄をいったん空欄にできるようにする,ただし空欄でない場合は0以上のみOK
setNewStock({ ...newStock, price: parsedValue }); // number型で保存
}
}}
// sx={{ width: "50%" }}
type="number"
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} // ここで整数のみ許可
/>
{/* 購入店舗入力フィールド */}
<TextField
margin="dense"
label="購入店舗"
fullWidth
value={newStock.shop}
onChange={(e) => setNewStock({ ...newStock, shop: e.target.value })}
/>
{/* 購入日・消費期限を横並びに */}
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
{/* 購入日・賞味期限入力 */}
<BuyExpDateSelect newStock={newStock} setNewStock={({ buyDate, expDate }) => setNewStock({ ...newStock, buyDate, expDate })} />
</Box>
</AddStuffAmountDialog>
{/* 各カテゴリを表示 */}
{CATEGORY_NAMES.map(category => {
return (
<Box sx={{ padding: "1rem" }}>
<Typography variant="h5" component="h1" gutterBottom
onClick={() => setOpenCategory({ ...openCategory, [category]: !openCategory[category] })}
>
{!openCategory[category] ? <ArrowDownIcon color="primary" /> : <ArrowUpIcon color="primary" />}
{category}
</Typography>
{openCategory[category] && (!stocks
? <Typography>...</Typography>
: StockTable(stocks, [category])
)}
</Box>
)
})}
{/* 材料ボタン - 画面下部に固定表示 */}
<Box className="plusButtonWrapper">
<Fab color="primary" onClick={() => setOpenAddDialog(true)} className="plusButton">
<AddIcon />
</Fab>
{/* <Typography className="plusButtonLabel">
材料の追加
</Typography> */}
</Box>
</div>
);
};
export default StockPage;