timetable-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 407260f658f887486f31face789212f0f9b9c39d
parent 6b69da29adc6d62cc2220467e6ce064599292e8e
Author: Yuukin256 <Yuukin256@gmail.com>
Date:   Fri,  4 Nov 2022 00:15:05 +0900

クラス別時間割表データ管理画面を実装

Diffstat:
Asrc/hooks/useStandardTimetable.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/pages/_app.tsx | 26+++++++++++++++-----------
Asrc/pages/console/standard.tsx | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 305 insertions(+), 11 deletions(-)

diff --git a/src/hooks/useStandardTimetable.ts b/src/hooks/useStandardTimetable.ts @@ -0,0 +1,97 @@ +import useAspidaSWR from '@aspida/swr'; +import { useSnackbar } from 'notistack'; +import { useBoolean } from 'react-use'; + +import { apiClient } from 'lib/apiClient'; + +import type { AbstractLessonNameEn, StandardEn } from 'types'; + +type Props = { + classId: number; +}; + +type Result = { + loading: boolean; + standard: StandardEn | undefined; + error: unknown; + set: (key: AbstractLessonNameEn, value: string[]) => void; + submit: () => void; +}; + +const fallbackData: StandardEn = { + Mon1: [], + Mon2: [], + Mon3: [], + Mon4: [], + Mon5: [], + Mon6: [], + Mon7: [], + Tue1: [], + Tue2: [], + Tue3: [], + Tue4: [], + Tue5: [], + Tue6: [], + Tue7: [], + Wed1: [], + Wed2: [], + Wed3: [], + Wed4: [], + Wed5: [], + Wed6: [], + Wed7: [], + Thu1: [], + Thu2: [], + Thu3: [], + Thu4: [], + Thu5: [], + Thu6: [], + Fri1: [], + Fri2: [], + Fri3: [], + Fri4: [], + Fri5: [], + Fri6: [], + Fri7: [], +}; + +const useStandardTimetable = (props: Props): Result => { + const { enqueueSnackbar } = useSnackbar(); + + const { data, error, mutate } = useAspidaSWR(apiClient.classes._classId(props.classId).standard, { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + onError() { + enqueueSnackbar('エラーが発生しました', { variant: 'error' }); + }, + }); + + const [submitting, setSubmitting] = useBoolean(false); + const standard = data ?? fallbackData; + + const set = (key: AbstractLessonNameEn, value: string[]) => { + mutate({ ...standard, [key]: value }, false); + }; + + const submit = () => { + setSubmitting(true); + apiClient.classes + ._classId(props.classId) + .standard.$post({ body: standard }) + .then((newData) => mutate(newData)) + .then(() => enqueueSnackbar('正常に保存されました', { variant: 'success' })) + .catch(() => enqueueSnackbar('エラーが発生しました', { variant: 'error' })) + .finally(() => setSubmitting(false)); + }; + + return { + loading: (!data && !error) || submitting, + standard: data, + error, + set, + submit, + }; +}; + +export default useStandardTimetable; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx @@ -2,11 +2,13 @@ import { CacheProvider } from '@emotion/react'; import CssBaseline from '@mui/material/CssBaseline'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import Head from 'next/head'; +import { SnackbarProvider } from 'notistack'; import createEmotionCache from 'lib/createEmotionCache'; import type { EmotionCache } from '@emotion/react'; import type { AppProps } from 'next/app'; + import 'styles/global.css'; const clientSideEmotionCache = createEmotionCache(); @@ -15,22 +17,24 @@ interface MyAppProps extends AppProps { emotionCache?: EmotionCache; } -function MyApp(props: MyAppProps) { +function App(props: MyAppProps) { const { Component, emotionCache = clientSideEmotionCache, pageProps } = props; const theme = createTheme(); return ( - <CacheProvider value={emotionCache}> - <Head> - <meta name='viewport' content='initial-scale=1, width=device-width' /> - </Head> - <ThemeProvider theme={theme}> - <CssBaseline /> - <Component {...pageProps} /> - </ThemeProvider> - </CacheProvider> + <SnackbarProvider maxSnack={3}> + <CacheProvider value={emotionCache}> + <Head> + <meta name='viewport' content='initial-scale=1, width=device-width' /> + </Head> + <ThemeProvider theme={theme}> + <CssBaseline /> + <Component {...pageProps} /> + </ThemeProvider> + </CacheProvider> + </SnackbarProvider> ); } -export default MyApp; +export default App; diff --git a/src/pages/console/standard.tsx b/src/pages/console/standard.tsx @@ -0,0 +1,193 @@ +import useAspidaSWR from '@aspida/swr'; +import SendIcon from '@mui/icons-material/Send'; +import Backdrop from '@mui/material/Backdrop'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Select from '@mui/material/Select'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import TextField from '@mui/material/TextField'; +import { useSnackbar } from 'notistack'; +import { useState } from 'react'; +import { SWRConfig } from 'swr'; + +import useStandardTimetable from 'hooks/useStandardTimetable'; +import { apiClient } from 'lib/apiClient'; + +import type { NextPage } from 'next'; +import type { FC } from 'react'; +import type { AbstractLessonNameEn, StandardEn } from 'types'; +const ClassUnitCell: FC<{ subjects: string[]; handleChange: (newValue: string) => void; tabIndex: number }> = ({ + subjects, + handleChange, + tabIndex, +}) => { + return ( + <TableCell> + <TextField + variant='standard' + value={subjects.join('/')} + onChange={(e) => handleChange(e.target.value)} + inputProps={{ tabIndex: tabIndex }} + /> + </TableCell> + ); +}; + +const StandardTimetableTable: FC<{ data: StandardEn; set: (key: AbstractLessonNameEn, value: string[]) => void }> = ({ + data, + set, +}) => { + const getCellParams = (abstractLessonName: AbstractLessonNameEn) => { + return { + subjects: data[abstractLessonName], + handleChange: (newValue: string) => + set( + abstractLessonName, + newValue.split('/').map((v) => v.trim()) + ), + }; + }; + + return ( + <TableContainer component={Paper}> + <Table> + <TableHead> + <TableRow> + <TableCell sx={{ width: '3rem' }}></TableCell> + <TableCell sx={{ width: '8rem' }}>月</TableCell> + <TableCell sx={{ width: '8rem' }}>火</TableCell> + <TableCell sx={{ width: '8rem' }}>水</TableCell> + <TableCell sx={{ width: '8rem' }}>木</TableCell> + <TableCell sx={{ width: '8rem' }}>金</TableCell> + </TableRow> + </TableHead> + <TableBody> + <TableRow> + <TableCell>1</TableCell> + <ClassUnitCell {...getCellParams('Mon1')} tabIndex={111} /> + <ClassUnitCell {...getCellParams('Tue1')} tabIndex={121} /> + <ClassUnitCell {...getCellParams('Wed1')} tabIndex={131} /> + <ClassUnitCell {...getCellParams('Thu1')} tabIndex={141} /> + <ClassUnitCell {...getCellParams('Fri1')} tabIndex={151} /> + </TableRow> + <TableRow> + <TableCell>2</TableCell> + <ClassUnitCell {...getCellParams('Mon2')} tabIndex={112} /> + <ClassUnitCell {...getCellParams('Tue2')} tabIndex={122} /> + <ClassUnitCell {...getCellParams('Wed2')} tabIndex={132} /> + <ClassUnitCell {...getCellParams('Thu2')} tabIndex={142} /> + <ClassUnitCell {...getCellParams('Fri2')} tabIndex={152} /> + </TableRow> + <TableRow> + <TableCell>3</TableCell> + <ClassUnitCell {...getCellParams('Mon3')} tabIndex={113} /> + <ClassUnitCell {...getCellParams('Tue3')} tabIndex={123} /> + <ClassUnitCell {...getCellParams('Wed3')} tabIndex={133} /> + <ClassUnitCell {...getCellParams('Thu3')} tabIndex={143} /> + <ClassUnitCell {...getCellParams('Fri3')} tabIndex={153} /> + </TableRow> + <TableRow> + <TableCell>4</TableCell> + <ClassUnitCell {...getCellParams('Mon4')} tabIndex={114} /> + <ClassUnitCell {...getCellParams('Tue4')} tabIndex={124} /> + <ClassUnitCell {...getCellParams('Wed4')} tabIndex={134} /> + <ClassUnitCell {...getCellParams('Thu4')} tabIndex={144} /> + <ClassUnitCell {...getCellParams('Fri4')} tabIndex={154} /> + </TableRow> + <TableRow> + <TableCell>5</TableCell> + <ClassUnitCell {...getCellParams('Mon5')} tabIndex={115} /> + <ClassUnitCell {...getCellParams('Tue5')} tabIndex={125} /> + <ClassUnitCell {...getCellParams('Wed5')} tabIndex={135} /> + <ClassUnitCell {...getCellParams('Thu5')} tabIndex={145} /> + <ClassUnitCell {...getCellParams('Fri5')} tabIndex={155} /> + </TableRow> + <TableRow> + <TableCell>6</TableCell> + <ClassUnitCell {...getCellParams('Mon6')} tabIndex={116} /> + <ClassUnitCell {...getCellParams('Tue6')} tabIndex={126} /> + <ClassUnitCell {...getCellParams('Wed6')} tabIndex={136} /> + <ClassUnitCell {...getCellParams('Thu6')} tabIndex={146} /> + <ClassUnitCell {...getCellParams('Fri6')} tabIndex={156} /> + </TableRow> + <TableRow> + <TableCell>7</TableCell> + <ClassUnitCell {...getCellParams('Mon7')} tabIndex={117} /> + <ClassUnitCell {...getCellParams('Tue7')} tabIndex={127} /> + <ClassUnitCell {...getCellParams('Wed7')} tabIndex={137} /> + <TableCell></TableCell> + <ClassUnitCell {...getCellParams('Fri7')} tabIndex={157} /> + </TableRow> + </TableBody> + </Table> + </TableContainer> + ); +}; + +const StandardTimetableForm: FC<{ classId: number }> = ({ classId }) => { + const { standard, loading, set, submit } = useStandardTimetable({ classId }); + + // TODO: Backdrop を app 共通にして context or SWR で制御 + return ( + <> + <Backdrop open={loading}> + <CircularProgress color='inherit' /> + </Backdrop> + {standard && <StandardTimetableTable data={standard} set={set} />} + {standard && ( + <Button variant='contained' disabled={loading} sx={{ m: 4 }} endIcon={<SendIcon />} onClick={() => submit()}> + 送信 + </Button> + )} + </> + ); +}; + +const Page: NextPage = () => { + const { enqueueSnackbar } = useSnackbar(); + const { data } = useAspidaSWR(apiClient.classes); + + const classes = data ?? []; + const [classId, setClassId] = useState<number>(); + + // TODO: classes 取得時に Backdrop + // TODO: SWRConfig を _app.tsx へ + return ( + <SWRConfig + value={{ + onError() { + enqueueSnackbar('エラーが発生しました', { variant: 'error' }); + }, + }} + > + <Container maxWidth='lg'> + <Box width='7rem' m={4}> + <FormControl fullWidth> + <InputLabel>クラス</InputLabel> + <Select value={classId ?? ''} label='クラス' onChange={(e) => setClassId(e.target.value as number)}> + {classes.map((c) => ( + <MenuItem value={c.id} key={c.id}> + {c.grade}-{c.class} + </MenuItem> + ))} + </Select> + </FormControl> + </Box> + {classId && <StandardTimetableForm classId={classId} />} + </Container> + </SWRConfig> + ); +}; + +export default Page;