-
Notifications
You must be signed in to change notification settings - Fork 2
Feat/walt/purchase report sort frontend #1044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
eda29a3
3fa14ff
9d47aeb
44167c1
3b6ec0a
3c61495
dff5edd
72f01f1
95d6546
2c7c7b7
2894b01
d88ee44
96fdbdb
29d8823
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import React, { FC, useEffect, useMemo, useState } from 'react'; | ||
|
|
||
| import { CloseButton, Modal, OutlinePrimaryButton, Select } from '@components/common'; | ||
| import { Bureau, User } from '@type/common'; | ||
|
|
||
| interface PurchaseReportPaidByFilterModalProps { | ||
| isOpen: boolean; | ||
| onClose: () => void; | ||
| onApply: (selection: { | ||
| bureauId: number | null; | ||
| paidByUserId: number | null | undefined; | ||
| }) => void; | ||
| bureaus: Bureau[]; | ||
| users: User[]; | ||
| selectedBureauId: number | null; | ||
| selectedPaidByUserId: number | null | undefined; | ||
| } | ||
|
|
||
| const PurchaseReportPaidByFilterModal: FC<PurchaseReportPaidByFilterModalProps> = (props) => { | ||
| const { isOpen, onClose, onApply, bureaus, users, selectedBureauId, selectedPaidByUserId } = | ||
| props; | ||
|
|
||
| const [draftBureauId, setDraftBureauId] = useState<number | null>(selectedBureauId); | ||
| const [draftPaidByUserId, setDraftPaidByUserId] = useState<number | null | undefined>( | ||
| selectedPaidByUserId, | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (!isOpen) return; | ||
| setDraftBureauId(selectedBureauId); | ||
| setDraftPaidByUserId(selectedPaidByUserId); | ||
| }, [isOpen, selectedBureauId, selectedPaidByUserId]); | ||
|
|
||
| const bureauNameMap = useMemo( | ||
| () => | ||
| new Map( | ||
| bureaus.map((bureau) => [bureau.id ?? 0, bureau.name] as const).filter(([id]) => id > 0), | ||
| ), | ||
| [bureaus], | ||
| ); | ||
|
|
||
| const filteredUsers = useMemo(() => { | ||
| if (!draftBureauId) return users; | ||
| return users.filter((user) => user.bureauID === draftBureauId); | ||
| }, [draftBureauId, users]); | ||
|
|
||
| const paidBySelectValue = draftPaidByUserId === null ? 'none' : draftPaidByUserId ?? ''; | ||
|
|
||
| const handleBureauChange = (event: React.ChangeEvent<HTMLSelectElement>) => { | ||
| const value = event.target.value; | ||
| const nextBureauId = value === '' ? null : Number(value); | ||
| setDraftBureauId(nextBureauId); | ||
| setDraftPaidByUserId(undefined); | ||
| }; | ||
|
|
||
| const handlePaidByChange = (event: React.ChangeEvent<HTMLSelectElement>) => { | ||
| const value = event.target.value; | ||
| if (value === '') { | ||
| setDraftPaidByUserId(undefined); | ||
| return; | ||
| } | ||
| if (value === 'none') { | ||
| setDraftPaidByUserId(null); | ||
| return; | ||
| } | ||
| setDraftPaidByUserId(Number(value)); | ||
| }; | ||
|
|
||
| const handleApply = () => { | ||
| onApply({ | ||
| bureauId: draftBureauId ?? null, | ||
| paidByUserId: draftPaidByUserId, | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <Modal className='w-[90vw] max-w-[440px] p-6 shadow-lg' onClick={onClose}> | ||
| <div className='flex justify-end'> | ||
| <CloseButton onClick={onClose} /> | ||
| </div> | ||
| <div className='mt-2 space-y-5'> | ||
| <div> | ||
| <p className='mb-2 text-sm text-black-600'>局名</p> | ||
| <Select value={draftBureauId ?? ''} onChange={handleBureauChange}> | ||
| <option value=''>絞り込みなし</option> | ||
| {bureaus.map((bureau) => ( | ||
| <option key={bureau.id ?? 0} value={bureau.id ?? 0}> | ||
| {bureau.name} | ||
| </option> | ||
| ))} | ||
| </Select> | ||
| </div> | ||
| <div> | ||
| <p className='mb-2 text-sm text-black-600'>氏名</p> | ||
| <Select value={paidBySelectValue} onChange={handlePaidByChange}> | ||
| <option value='none'>絞り込みなし</option> | ||
| {filteredUsers.map((user) => { | ||
| const bureauName = bureauNameMap.get(user.bureauID); | ||
| const label = draftBureauId || !bureauName ? user.name : `${bureauName} ${user.name}`; | ||
| return ( | ||
| <option key={user.id} value={user.id}> | ||
| {label} | ||
| </option> | ||
| ); | ||
| })} | ||
| </Select> | ||
| {/* NOTE: paid_by_user_id の NULL を絞り込む仕様が未定義のため「立替者なし」は未実装。 */} | ||
| </div> | ||
| </div> | ||
| <div className='mt-6 flex justify-center'> | ||
| <OutlinePrimaryButton onClick={handleApply}>絞り込む</OutlinePrimaryButton> | ||
| </div> | ||
| </Modal> | ||
| ); | ||
| }; | ||
|
|
||
| export default PurchaseReportPaidByFilterModal; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import React from 'react'; | ||
|
|
||
| interface PurchaseReportSummaryAmountsProps { | ||
| unsettledAmountText: string; | ||
| unpackedAmountText: string; | ||
| className?: string; | ||
| } | ||
|
|
||
| export default function PurchaseReportSummaryAmounts({ | ||
| unsettledAmountText, | ||
| unpackedAmountText, | ||
| className = 'text-sm text-black-600 md:ml-auto', | ||
| }: PurchaseReportSummaryAmountsProps) { | ||
| return ( | ||
| <div className={className}> | ||
| <div className='inline-grid grid-cols-[auto_auto_auto] gap-1'> | ||
| <span className='whitespace-nowrap'>未清算金額</span> | ||
| <span className='whitespace-nowrap'>:</span> | ||
| <span className='min-w-[12ch] whitespace-nowrap text-right'>{unsettledAmountText} 円</span> | ||
| <span className='whitespace-nowrap'>未封詰め金額</span> | ||
| <span className='whitespace-nowrap'>:</span> | ||
| <span className='min-w-[12ch] whitespace-nowrap text-right'>{unpackedAmountText} 円</span> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,20 @@ | ||
| import { saveAs } from 'file-saver'; | ||
| import { useRouter } from 'next/router'; | ||
| import { useCallback, useState, useEffect, useMemo } from 'react'; | ||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | ||
| import { RiArrowDropDownLine } from 'react-icons/ri'; | ||
| import { TbDownload } from 'react-icons/tb'; | ||
| import { useRecoilValue } from 'recoil'; | ||
|
|
||
| import DownloadButton from '@/components/common/DownloadButton'; | ||
| import PrimaryButton from '@/components/common/OutlinePrimaryButton/OutlinePrimaryButton'; | ||
| import { OpenCheckSettlementModalButton } from '@/components/purchasereports'; | ||
| import PurchaseReportPaidByFilterModal from '@/components/purchasereports/PurchaseReportPaidByFilterModal'; | ||
| import PurchaseReportSummaryAmounts from '@/components/purchasereports/PurchaseReportSummaryAmounts'; | ||
| import { BUREAUS } from '@/constants/bureaus'; | ||
| import { | ||
| useGetBuyReportsDetails, | ||
| useGetBuyReportsSummary, | ||
| useGetUsers, | ||
| useGetYearsPeriods, | ||
| usePutBuyReportStatusBuyReportId, | ||
| } from '@/generated/hooks'; | ||
|
|
@@ -18,10 +24,12 @@ import MainLayout from '@components/layout/MainLayout'; | |
| import OpenDeleteModalButton from '@components/purchasereports/OpenDeleteModalButton'; | ||
|
|
||
| import type { | ||
| GetBuyReportsDetailsParams, | ||
| BuyReportDetail, | ||
| GetBuyReportsDetailsParams, | ||
| GetBuyReportsSummaryParams, | ||
| PutBuyReportStatusBuyReportIdBody, | ||
| } from '@/generated/model'; | ||
| import type { User } from '@type/common'; | ||
|
|
||
| export default function PurchaseReports() { | ||
| const router = useRouter(); | ||
|
|
@@ -31,30 +39,64 @@ export default function PurchaseReports() { | |
| error: yearPeriodsError, | ||
| } = useGetYearsPeriods(); | ||
| const yearPeriods = yearPeriodsData?.data; | ||
|
|
||
| const user = useRecoilValue(userAtom); | ||
|
|
||
| const { data: usersResponse } = useGetUsers(); | ||
| const users = useMemo(() => { | ||
| const responseData = usersResponse?.data as User[] | { data?: User[] } | undefined; | ||
| if (Array.isArray(responseData)) return responseData; | ||
| return responseData?.data ?? []; | ||
|
Comment on lines
44
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
現状のコードは型アサーション 根本的な解決としてはAPIスキーマや |
||
| }, [usersResponse]); | ||
|
|
||
| user?.roleID === 1 && router.push('/my_page'); | ||
|
|
||
| const [selectedYear, setSelectedYear] = useState<number>( | ||
| yearPeriods && yearPeriods.length > 0 ? yearPeriods[yearPeriods.length - 1].year : 0, | ||
|
Comment on lines
53
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ); | ||
|
|
||
| useEffect(() => { | ||
| if (yearPeriods && yearPeriods.length > 0) { | ||
| const latestYear = Math.max(...yearPeriods.map((period) => period.year)); | ||
| setSelectedYear(latestYear); | ||
| } | ||
| }, [yearPeriods]); | ||
|
|
||
| const [selectedYear, setSelectedYear] = useState<number>( | ||
| yearPeriods && yearPeriods.length > 0 ? yearPeriods[yearPeriods.length - 1].year : 0, | ||
| const [isPaidByFilterOpen, setIsPaidByFilterOpen] = useState(false); | ||
| const [selectedBureauId, setSelectedBureauId] = useState<number | null>(null); | ||
| const [selectedPaidByUserId, setSelectedPaidByUserId] = useState<number | null | undefined>( | ||
| undefined, | ||
| ); | ||
| const getBuyReportsDetailsParams: GetBuyReportsDetailsParams = { year: selectedYear }; | ||
|
|
||
| const getBuyReportsDetailsParams: GetBuyReportsDetailsParams = { | ||
| year: selectedYear, | ||
| ...(selectedBureauId != null ? { financial_record_id: selectedBureauId } : {}), | ||
| ...(selectedPaidByUserId != null ? { paid_by_user_id: selectedPaidByUserId } : {}), | ||
| }; | ||
|
|
||
| const { | ||
| data: buyReportsData, | ||
| isLoading: isBuyReportsLoading, | ||
| error: buyReportsError, | ||
| mutate: mutateBuyReportData, | ||
| } = useGetBuyReportsDetails(getBuyReportsDetailsParams); | ||
|
|
||
| const buyReports = useMemo(() => buyReportsData?.data ?? [], [buyReportsData]); | ||
|
|
||
| const getBuyReportsSummaryParams: GetBuyReportsSummaryParams = { | ||
| year: selectedYear, | ||
| ...(selectedBureauId != null ? { financial_record_id: selectedBureauId } : {}), | ||
| ...(selectedPaidByUserId != null ? { paid_by_user_id: selectedPaidByUserId } : {}), | ||
| }; | ||
|
|
||
| const { | ||
| data: buyReportsSummaryData, | ||
| isLoading: isBuyReportsSummaryLoading, | ||
| error: buyReportsSummaryError, | ||
| } = useGetBuyReportsSummary(getBuyReportsSummaryParams, { | ||
| swr: { enabled: selectedYear > 0 }, | ||
| }); | ||
|
|
||
| const [sealChecks, setSealChecks] = useState<Record<number, boolean>>({}); | ||
| const [settlementChecks, setSettlementChecks] = useState<Record<number, boolean>>({}); | ||
|
|
||
|
|
@@ -109,6 +151,18 @@ export default function PurchaseReports() { | |
| return amount.toLocaleString(); | ||
| }, []); | ||
|
|
||
| const buyReportsSummary = buyReportsSummaryData?.data; | ||
|
|
||
| const summaryUnsettledAmount = | ||
| isBuyReportsSummaryLoading || buyReportsSummaryError || buyReportsSummary == null | ||
| ? '-' | ||
| : formatAmount(buyReportsSummary.unsettledAmount ?? 0); | ||
|
|
||
| const summaryUnpackedAmount = | ||
| isBuyReportsSummaryLoading || buyReportsSummaryError || buyReportsSummary == null | ||
| ? '-' | ||
| : formatAmount(buyReportsSummary.unpackedAmount ?? 0); | ||
|
|
||
| const download = async (url: string, fileName: string) => { | ||
| const downloadPath = `${process.env.NEXT_PUBLIC_MINIO_ENDPONT}/finansu/${url}`; | ||
| const response = await fetch(downloadPath); | ||
|
|
@@ -183,8 +237,29 @@ export default function PurchaseReports() { | |
| CSVダウンロード | ||
| <TbDownload className='ml-2' size={20} /> | ||
| </PrimaryButton> | ||
| <PurchaseReportSummaryAmounts | ||
| unsettledAmountText={summaryUnsettledAmount} | ||
| unpackedAmountText={summaryUnpackedAmount} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {isPaidByFilterOpen && ( | ||
| <PurchaseReportPaidByFilterModal | ||
| isOpen={isPaidByFilterOpen} | ||
| onClose={() => setIsPaidByFilterOpen(false)} | ||
| onApply={({ bureauId, paidByUserId }) => { | ||
| setSelectedBureauId(bureauId); | ||
| setSelectedPaidByUserId(paidByUserId); | ||
| setIsPaidByFilterOpen(false); | ||
| }} | ||
| bureaus={BUREAUS} | ||
| users={users} | ||
| selectedBureauId={selectedBureauId} | ||
| selectedPaidByUserId={selectedPaidByUserId} | ||
| /> | ||
| )} | ||
|
|
||
| <div className='mt-2 flex-1 overflow-auto p-4 md:p-8'> | ||
| <div className='min-w-max'> | ||
| <table className='mb-5 table-auto border-collapse'> | ||
|
|
@@ -203,7 +278,17 @@ export default function PurchaseReports() { | |
| 物品 | ||
| </th> | ||
| <th className='whitespace-nowrap px-4 pb-2 text-sm font-normal text-black-600'> | ||
| 立替者 | ||
| <div className='flex items-center justify-center gap-1'> | ||
| <span>立替者</span> | ||
| <button | ||
| type='button' | ||
| className='rounded-full p-0.5 text-black-600 hover:bg-white-100' | ||
| onClick={() => setIsPaidByFilterOpen(true)} | ||
| aria-label='立替者の絞り込み' | ||
| > | ||
| <RiArrowDropDownLine size={20} /> | ||
| </button> | ||
| </div> | ||
| </th> | ||
| <th className='whitespace-nowrap px-4 pb-2 text-sm font-normal text-black-600'> | ||
| 金額 | ||
|
|
@@ -217,6 +302,7 @@ export default function PurchaseReports() { | |
| <th className='whitespace-nowrap px-4 pb-2 text-sm text-black-600'></th> | ||
| </tr> | ||
| </thead> | ||
|
|
||
| <tbody> | ||
| {buyReports && buyReports.length > 0 ? ( | ||
| buyReports.map((report) => ( | ||
|
|
@@ -239,6 +325,7 @@ export default function PurchaseReports() { | |
| <td className='whitespace-nowrap px-4 py-3 text-center text-sm text-black-600'> | ||
| {formatAmount(report.amount ?? 0)} | ||
| </td> | ||
|
|
||
| <td className='px-4 py-2 text-center'> | ||
| <Checkbox | ||
| className='accent-primary-5' | ||
|
|
@@ -249,6 +336,7 @@ export default function PurchaseReports() { | |
| }} | ||
| /> | ||
| </td> | ||
|
|
||
| <td className='px-4 py-2 text-center'> | ||
| <OpenCheckSettlementModalButton | ||
| id={report.id ?? 0} | ||
|
|
@@ -260,6 +348,7 @@ export default function PurchaseReports() { | |
| disabled={!sealChecks[report.id ?? 0]} | ||
| /> | ||
| </td> | ||
|
|
||
| <td> | ||
| <div className='flex'> | ||
| <div className='mx-1'> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
draftPaidByUserIdの状態管理にnumber | null | undefinedの3つの型が使われており、コードが少し複雑になっています。現状ではnullとundefinedが両方とも「絞り込みなし」を意味するように見え、冗長です。draftBureauIdがnumber | nullで管理されているように、draftPaidByUserIdもnumber | nullに統一し、nullを「絞り込みなし」として扱うことで、コードをシンプルにし、一貫性を高めることができます。以下の提案では、関連する状態とハンドラを
nullを使うように修正しています。これに合わせて、96行目の
optionのvalueも'none'から''に変更してください:<option value=''>絞り込みなし</option>