料理追加・編集画面を実装

feature-frontend-dishedit-kato
Masaharu.Kato 4 months ago
parent 8fd8771ac5
commit 75b055574f
  1. 4
      backend/src/main/java/com/example/todoapp/dto/RecipeDetailDTO.java
  2. 7
      backend/src/main/java/com/example/todoapp/dto/StuffDetailDTO.java
  3. 40
      backend/src/main/java/com/example/todoapp/service/RecipeService.java
  4. 17
      frontend/src/App.tsx
  5. 10
      frontend/src/constants/errorMessages.ts
  6. 261
      frontend/src/pages/AddRecipe.tsx
  7. 76
      frontend/src/services/api.ts
  8. 10
      frontend/src/types/types.ts

@ -12,7 +12,7 @@ import lombok.Data;
* </p>
*/
@Data
public class RecipeDetailDTO {
public class RecipeDetailDTO {
/**
* レシピID
* ユニークなレシピを識別するためのID
@ -35,5 +35,5 @@ public class RecipeDetailDTO {
* 食材リスト
* このレシピに必要な食材とその数量のリスト
*/
private List<StuffDetailDTO> stuffs;
private List<StuffDetailDTO> stuffAndAmountArray;
}

@ -1,6 +1,7 @@
package com.example.todoapp.dto;
import lombok.Data;
/**
* 食材詳細情報のデータ転送オブジェクトDTO
* <p>
@ -22,6 +23,12 @@ public class StuffDetailDTO {
*/
private String stuffName;
/**
* カテゴリ名
* 食材のカテゴリ
*/
private String category;
/**
* 数量
* レシピに必要な食材の量

@ -79,13 +79,13 @@ public class RecipeService {
RecipeStuffs recipeStuffs = new RecipeStuffs();
recipeStuffs.setRecipes(recipe); // 関連レシピ設定
recipeStuffs.setStuff(stuff); // 関連材料設定
recipeStuffs.setStuff(stuff); // 関連材料設定
// 数量設定、defaultは1
if (stuffDTO.getAmount() == null || stuffDTO.getAmount().isEmpty()) {
stuffDTO.setAmount("1");
}
recipeStuffs.setAmount(Integer.parseInt(stuffDTO.getAmount()));
recipeStuffs.setAmount(Integer.parseInt(stuffDTO.getAmount()));
recipeStuffsList.add(recipeStuffs);
}
@ -111,26 +111,28 @@ public class RecipeService {
*/
public RecipeDetailDTO getRecipeDetailsById(Long recipeId) {
Recipes recipe = recipesRepository.findById(recipeId)
.orElseThrow(() -> new RuntimeException("レシピが見つかりません"));
.orElseThrow(() -> new RuntimeException("レシピが見つかりません"));
List<RecipeStuffs> recipeStuffsList = recipeStuffsRepository.findByRecipesRecipeId(recipeId);
List<StuffDetailDTO> stuffList = recipeStuffsList.stream()
.map(rs -> {
StuffDetailDTO stuffDTO = new StuffDetailDTO();
stuffDTO.setStuffId(rs.getStuff().getStuffId());
stuffDTO.setStuffName(rs.getStuff().getStuffName());
stuffDTO.setAmount(rs.getAmount());
return stuffDTO;
})
.collect(Collectors.toList());
.map(rs -> {
StuffDetailDTO stuffDTO = new StuffDetailDTO();
Stuffs stuff = rs.getStuff();
stuffDTO.setStuffId(stuff.getStuffId());
stuffDTO.setStuffName(stuff.getStuffName());
stuffDTO.setCategory(stuff.getCategory());
stuffDTO.setAmount(rs.getAmount());
return stuffDTO;
})
.collect(Collectors.toList());
RecipeDetailDTO dto = new RecipeDetailDTO();
dto.setRecipeId(recipe.getRecipeId());
dto.setRecipeName(recipe.getRecipeName());
dto.setSummary(recipe.getSummary());
dto.setStuffs(stuffList);
dto.setStuffAndAmountArray(stuffList);
return dto;
}
@ -145,7 +147,7 @@ public class RecipeService {
public Recipes updateRecipe(RecipeDetailDTO dto) {
// IDでレシピを検索し、見つからない場合は例外をスロー
Recipes recipe = recipesRepository.findById(dto.getRecipeId())
.orElseThrow(() -> new RuntimeException("レシピが見つかりません"));
.orElseThrow(() -> new RuntimeException("レシピが見つかりません"));
// レシピ名と概要を更新
recipe.setRecipeName(dto.getRecipeName());
@ -155,12 +157,12 @@ public class RecipeService {
Set<Long> incomingStuffIds = new HashSet<>();
// 提供された材料の詳細を繰り返し処理
for (StuffDetailDTO stuffDTO : dto.getStuffs()) {
for (StuffDetailDTO stuffDTO : dto.getStuffAndAmountArray()) {
if (stuffDTO.getStuffId() == null) {
// 材料IDがnullの場合、新しい材料を作成
Stuffs newStuff = new Stuffs();
newStuff.setStuffName(stuffDTO.getStuffName());
newStuff.setCategory("その他");
newStuff.setCategory("その他");
newStuff = stuffsRepository.save(newStuff);
// 新しいRecipeStuffsエントリを作成
@ -174,7 +176,7 @@ public class RecipeService {
} else {
// 材料IDが提供されている場合、既存のRecipeStuffsエントリを検索
Optional<RecipeStuffs> optionalRs = recipeStuffsRepository
.findByRecipesRecipeIdAndStuffStuffId(dto.getRecipeId(), stuffDTO.getStuffId());
.findByRecipesRecipeIdAndStuffStuffId(dto.getRecipeId(), stuffDTO.getStuffId());
if (optionalRs.isPresent()) {
// RecipeStuffsエントリが存在する場合、数量を更新
@ -185,7 +187,7 @@ public class RecipeService {
} else {
// オプション:見つからない場合、新しいRecipeStuffsエントリを作成
Stuffs existingStuff = stuffsRepository.findById(stuffDTO.getStuffId())
.orElseThrow(() -> new RuntimeException("材料が見つかりません"));
.orElseThrow(() -> new RuntimeException("材料が見つかりません"));
RecipeStuffs rs = new RecipeStuffs();
rs.setRecipes(recipe);
@ -207,4 +209,4 @@ public class RecipeService {
return recipe;
}
}
}

@ -108,16 +108,16 @@ const App: React.FC = () => {
</PrivateRoute>
}
/>
{/* テストページへのルートを追加 */}
{/* 料理リストへのルートを追加 */}
<Route
path="dishList"
element={
<PrivateRoute>
<DishList/>
<DishList />
</PrivateRoute>
}
/>
{/* テストページへのルートを追加 */}
{/* 料理追加ページ(新規追加)へのルートを追加 */}
<Route
path="addRecipe"
element={
@ -126,6 +126,15 @@ const App: React.FC = () => {
</PrivateRoute>
}
/>
{/* 料理追加ページ(編集)へのルートを追加 */}
<Route
path="addRecipe/:recipeId"
element={
<PrivateRoute>
<AddRecipe />
</PrivateRoute>
}
/>
</Route>
<Route path="/" element={<Layout />}>
{/* ルートパスへのアクセスはタスク一覧にリダイレクト */}
@ -140,7 +149,7 @@ const App: React.FC = () => {
</PrivateRoute>
}
/>
</Route>
</Routes>
</BrowserRouter>

@ -41,6 +41,16 @@ export const STOCK_ERRORS = {
BUY_FAILED: '在庫リストの購入処理に失敗しました',
};
// 料理リスト関連のエラーメッセージ
export const RECIPE_ERRORS = {
FETCH_FAILED: '料理リストの取得に失敗しました',
GET_FAILED: '料理情報の取得に失敗しました',
CREATE_FAILED: '料理リストの作成に失敗しました',
UPDATE_FAILED: '料理リストの更新に失敗しました',
DELETE_FAILED: '料理リストの削除に失敗しました',
};
// // タスク関連のエラーメッセージ
// export const TASK_ERRORS = {
// FETCH_FAILED: 'タスクの取得に失敗しました',

@ -29,15 +29,23 @@ import {
} from '@mui/material';
import {
Add as AddIcon, Delete as DeleteIcon, ShoppingBasket as ShoppingBasketIcon,
SoupKitchen as SoupKitchenIcon, Edit as EditIcon, ListAlt as ListAltIcon
SoupKitchen as SoupKitchenIcon, Edit as EditIcon, Save as SaveIcon, ListAlt as ListAltIcon
} from '@mui/icons-material';
import AddStuffAmountDialog from '../components/AddStuffAmountDialog';
import { StuffAndCategoryAndAmount, StuffNameAndAmount } from '../types/types';
import { StuffAndCategoryAndAmount } from '../types/types';
import EditAmountDialog from '../components/EditAmountDialog';
import { recipeApi, toBuyApi } from '../services/api';
import { useNavigate, useParams } from 'react-router-dom';
const AddDishes2: React.FC = () => {
// 料理名
const AddRecipe: React.FC = () => {
const { recipeId: recipeIdStr } = useParams();
const recipeId = recipeIdStr ? parseInt(recipeIdStr) : null
const navigate = useNavigate();
// 料理名,説明
const [recipeName, setRecipeName] = useState<string>('');
const [recipeSummary, setRecipeSummary] = useState<string>('');
// 材料リスト
const [items, setItems] = useState<StuffAndCategoryAndAmount[]>([]);
// 材料追加作成ダイアログの表示状態
@ -51,105 +59,158 @@ const AddDishes2: React.FC = () => {
const [editingItem, setEditingItem] = useState<StuffAndCategoryAndAmount>(emptyItem);
const [editingItemIdx, setEditingItemIdx] = useState(0);
const handleAddRecipeToBuy = () => {
console.log('追加された材料:', items);
const loadRecipe = async () => {
if (recipeId && !recipeName) {
const recipe = await recipeApi.getById(recipeId);
console.log('loaded recipe=', recipe)
setRecipeName(recipe.recipeName)
setRecipeSummary(recipe.summary)
setItems(recipe.stuffAndAmountArray)
}
}
loadRecipe();
const handleSaveRecipe = async () => {
if (!recipeId) {
// 新規追加
const response = await recipeApi.addRecipe({
recipeName,
summary: recipeSummary,
stuffAndAmountArray: items,
})
return response.recipeId;
}
const response = await recipeApi.updateRecipe({
recipeId,
recipeName,
summary: recipeSummary,
stuffAndAmountArray: items,
})
return recipeId;
}
const handleSubmit = async () => {
const recipeId = await handleSaveRecipe()
alert('レシピが保存されました!') // 仮メッセージ
// navigate('/tasks');
}
const handleSubmitAndAddToBuy = async () => {
const recipeId = await handleSaveRecipe();
await toBuyApi.addByRecipe(recipeId)
navigate('/tasks')
}
return (
<Box>
<div>
<h1></h1>
<TextField autoFocus margin="dense" label="料理名" fullWidth
value={recipeName} onChange={(e) => setRecipeName(e.target.value)}
/>
</div>
<h2></h2>
{/* すべての材料情報を表示 */}
{!items.length
? (<p>+</p>)
: (<List>{items.map((item, index) => (
<ListItem
key={index}
sx={{
bgcolor: 'background.paper',
mb: 1,
borderRadius: 1,
boxShadow: 1,
}}
>
<ListItemText primary={item.stuffName} />
{/* 買い物リスト:食材情報記入ボタン */}
<ListItemSecondaryAction>
<Typography variant="body1" component="span" sx={{ marginRight: '1em' }}>
{`× ${item.amount}`}
</Typography>
{/* 買い物リスト:数量変更ボタン */}
<Tooltip title="数量変更">
<IconButton sx={{ marginRight: 0, marginLeft: 0 }} edge="end" aria-label="数量変更"
onClick={() => {
setOpenAmountDialog(true)
setEditingItemIdx(index)
setEditingItem(item)
}}
>
<EditIcon />
</IconButton>
</Tooltip>
{/* 買い物リスト:食材削除ボタン */}
<Tooltip title="項目を削除"
componentsProps={{
tooltip: { sx: { backgroundColor: "white", color: "red", fontSize: "0.8rem", padding: "6px", borderRadius: "6px" } },
}}>
<IconButton edge="end" sx={{ marginRight: 0, marginLeft: 0 }} aria-label="delete"
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1)])}>
<DeleteIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}</List>)}
<div style={{ position: "fixed", left: "80%", transform: 'translateX(-50%)', bottom: "10%" }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="caption"></Typography>
</Box>
<Fab color="primary" onClick={() => setOpenAddDialog(true)}>
<AddIcon sx={{ fontSize: "1.5rem" }} />
</Fab>
</div>
<div style={{ position: "fixed", left: "50%", transform: 'translateX(-50%)', bottom: "2%" }}>
<Button variant='contained' sx={{ fontSize: "1.0rem" }}
color="primary" onClick={handleAddRecipeToBuy}>
<ListAltIcon sx={{ fontSize: "1.5rem", marginRight: "0.5rem" }} />
</Button>
</div>
{/* 新規材料追加ダイアログ */}
<AddStuffAmountDialog openDialog={openAddDialog} setOpenDialog={setOpenAddDialog} newItem={newItem} setNewItem={setNewItem}
onSubmit={() => {
console.log('newItem:', newItem);
setItems([...items, newItem]);
setOpenAddDialog(false);
}} />
{/* 数量変更ダイアログ */}
<EditAmountDialog openDialog={openAmountDialog} setOpenDialog={setOpenAmountDialog}
editingItem={editingItem}
setEditingItem={(v) => setEditingItem({ ...editingItem, ...v })}
onSubmit={() => {
setItems([...items.slice(0, editingItemIdx), editingItem, ...items.slice(editingItemIdx + 1)])
setOpenAmountDialog(false);
}} />
</Box>
(recipeId && !recipeName)
? <p>...</p>
:
<Box>
<div>
<h1>
<SoupKitchenIcon sx={{ marginRight: "0.5em" }} />
{!recipeId ? '料理の追加' : '料理の編集'}
</h1>
<TextField autoFocus margin="dense" label="料理名" fullWidth
value={recipeName} onChange={(e) => setRecipeName(e.target.value)}
/>
<TextField autoFocus margin="dense" label="説明" fullWidth
value={recipeSummary} onChange={(e) => setRecipeSummary(e.target.value)}
/>
</div>
<h2></h2>
{/* すべての材料情報を表示 */}
{(!items || !items.length)
? (<p>+</p>)
: (<List>{items.map((item, index) => (
<ListItem
key={index}
sx={{
bgcolor: 'background.paper',
mb: 1,
borderRadius: 1,
boxShadow: 1,
}}
>
<ListItemText primary={item.stuffName} />
{/* 買い物リスト:食材情報記入ボタン */}
<ListItemSecondaryAction>
<Typography variant="body1" component="span" sx={{ marginRight: '1em' }}>
{`× ${item.amount}`}
</Typography>
{/* 買い物リスト:数量変更ボタン */}
<Tooltip title="数量変更">
<IconButton sx={{ marginRight: 0, marginLeft: 0 }} edge="end" aria-label="数量変更"
onClick={() => {
setOpenAmountDialog(true)
setEditingItemIdx(index)
setEditingItem(item)
}}
>
<EditIcon />
</IconButton>
</Tooltip>
{/* 買い物リスト:食材削除ボタン */}
<Tooltip title="項目を削除"
componentsProps={{
tooltip: { sx: { backgroundColor: "white", color: "red", fontSize: "0.8rem", padding: "6px", borderRadius: "6px" } },
}}>
<IconButton edge="end" sx={{ marginRight: 0, marginLeft: 0 }} aria-label="delete"
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1)])}>
<DeleteIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}</List>)}
<div style={{ position: "fixed", left: "80%", transform: 'translateX(-50%)', bottom: "10%" }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="caption"></Typography>
</Box>
<Fab color="primary" onClick={() => setOpenAddDialog(true)}>
<AddIcon sx={{ fontSize: "1.5rem" }} />
</Fab>
</div>
<div style={{ position: "fixed", left: "50%", transform: 'translateX(-50%)', bottom: "2%" }}>
<Button variant='contained' color="primary" onClick={handleSubmit} sx={{ marginRight: "1rem" }}>
<SaveIcon sx={{ fontSize: "1.5rem", marginRight: "0.5rem" }} />
</Button>
<Button variant='contained' color="primary" onClick={handleSubmitAndAddToBuy}>
<ListAltIcon sx={{ fontSize: "1.5rem", marginRight: "0.5rem" }} />
</Button>
</div>
{/* 新規材料追加ダイアログ */}
<AddStuffAmountDialog openDialog={openAddDialog} setOpenDialog={setOpenAddDialog} newItem={newItem} setNewItem={setNewItem}
onSubmit={() => {
console.log('newItem:', newItem);
setItems([...items, newItem]);
setOpenAddDialog(false);
}} />
{/* 数量変更ダイアログ */}
<EditAmountDialog openDialog={openAmountDialog} setOpenDialog={setOpenAmountDialog}
editingItem={editingItem}
setEditingItem={(v) => setEditingItem({ ...editingItem, ...v })}
onSubmit={() => {
setItems([...items.slice(0, editingItemIdx), editingItem, ...items.slice(editingItemIdx + 1)])
setOpenAmountDialog(false);
}} />
</Box>
);
};
export default AddDishes2;
export default AddRecipe;

@ -3,8 +3,8 @@
* APIとの通信を担当するモジュール
*
*/
import { LoginCredentials, RegisterCredentials, AuthResponse, /* Task, */ ToBuy, Stuff, Stock, Recipes } from '../types/types';
import { AUTH_ERRORS, TOBUY_ERRORS, STOCK_ERRORS } from '../constants/errorMessages';
import { LoginCredentials, RegisterCredentials, AuthResponse, /* Task, */ ToBuy, Stuff, Stock, RecipeData, StuffAndCategoryAndAmount } from '../types/types';
import { AUTH_ERRORS, TOBUY_ERRORS, STOCK_ERRORS, RECIPE_ERRORS } from '../constants/errorMessages';
// APIのベースURL - 環境変数から取得するか、デフォルト値を使用
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';
@ -109,7 +109,7 @@ export const toBuyApi = {
* @param tobuy
* @returns
*/
addToBuy:async (tobuy: Omit<ToBuy, 'stuffId' | 'tobuyId'> & { stuffId: number | null, category: string }): Promise<any> => {
addToBuy: async (tobuy: Omit<ToBuy, 'stuffId' | 'tobuyId'> & { stuffId: number | null, category: string }): Promise<any> => {
const response = await fetch(`${API_BASE_URL}/api/tobuy/add`, {
method: 'POST',
headers: getHeaders(),
@ -132,6 +132,18 @@ export const toBuyApi = {
},
addByRecipe: async (recipeId: number): Promise<any> => {
const response = await fetch(`${API_BASE_URL}/api/tobuy/addByRecipe`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ recipeId }),
})
if (!response.ok) {
throw new Error(TOBUY_ERRORS.CREATE_FAILED);
}
return response.json();
},
/**
*
* @param id ID
@ -159,7 +171,7 @@ export const toBuyApi = {
* @param tobuy
* @returns
*/
updateToBuy: async (tobuy:ToBuy): Promise<any> => {
updateToBuy: async (tobuy: ToBuy): Promise<any> => {
const response = await fetch(`${API_BASE_URL}/api/tobuy/update`, {
method: 'PUT',
headers: getHeaders(),
@ -274,7 +286,7 @@ export const stockApi = {
req.buyDate = makeDateObject(req.buyDate)?.toISOString()?.substring(0, 10) || ''
req.expDate = makeDateObject(req.expDate)?.toISOString()?.substring(0, 10) || ''
console.log('req: ', req)
console.log('req: ', req)
const response = await fetch(`${API_BASE_URL}/api/stocks/update`, {
method: 'PUT',
@ -331,11 +343,13 @@ export const recipeApi = {
* @param recipeData
* @returns
*/
addRecipe: async (recipeData: Recipes): Promise<{
addRecipe: async (recipeData: RecipeData): Promise<{
result: string;
recipe_id: number;
recipeId: number;
message: string;
}> => {
console.log('recipeData:', recipeData)
const response = await fetch(`${API_BASE_URL}/api/recipes/add`, {
method: 'POST',
headers: getHeaders(), // 認証トークンを含むヘッダー
@ -344,7 +358,31 @@ export const recipeApi = {
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.message);
console.error('/api/recipes/add failed on backend:', errorData?.message)
throw new Error(RECIPE_ERRORS.CREATE_FAILED);
}
return response.json();
},
/**
*
* @param recipeData
* @returns /
*/
updateRecipe: async (recipeData: { recipeId: number } & RecipeData): Promise<{ result: boolean; message: string }> => {
// console.log('recipeData:', recipeData)
const response = await fetch(`${API_BASE_URL}/api/recipes/update`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(recipeData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error('/api/recipes/update failed on backend:', errorData?.message)
throw new Error(RECIPE_ERRORS.UPDATE_FAILED);
}
return response.json();
@ -382,7 +420,7 @@ export const recipeApi = {
recipeId: number;
recipeName: string;
summary: string;
stuffs: Array<{ stuffId: number; stuffName: string; amount: number }>;
stuffAndAmountArray: StuffAndCategoryAndAmount[];
}> => {
const response = await fetch(`${API_BASE_URL}/api/recipes/getById?recipeId=${recipeId}`, {
method: 'GET',
@ -396,26 +434,6 @@ export const recipeApi = {
return response.json();
},
/**
*
* @param recipeData
* @returns /
*/
update: async (recipeData: { recipeId: number } & Recipes): Promise<{ result: boolean; message: string }> => {
const response = await fetch(`${API_BASE_URL}/api/recipes/update`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(recipeData),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.message);
}
return response.json();
},
};

@ -123,16 +123,16 @@ export interface RegisterCredentials {
*
*
*/
export interface Recipes {
export interface RecipeData {
recipeName: string;// レシピ名
summary: string;// レシピ概要
// 材料リスト(直接配列として内包)
stuffAndAmountArray: Array<{
stuffAndAmountArray: {
// 既存材料IDまたは新規作成情報のどちらか一方のみ必要
stuffId?: number; // 既存材料ID(オプション)
stuffId: number | null; // 既存材料ID(オプション)
stuffName?: string;// 新規材料名(オプション)
category?: string; // 新規材料カテゴリ(オプション)
amount?: number; // 使用量(必須)
}>;
amount: number; // 使用量(必須)
}[];
}
Loading…
Cancel
Save