Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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));
};
Comment on lines +24 to +67

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

draftPaidByUserId の状態管理に number | null | undefined の3つの型が使われており、コードが少し複雑になっています。現状では nullundefined が両方とも「絞り込みなし」を意味するように見え、冗長です。
draftBureauIdnumber | null で管理されているように、draftPaidByUserIdnumber | null に統一し、null を「絞り込みなし」として扱うことで、コードをシンプルにし、一貫性を高めることができます。

以下の提案では、関連する状態とハンドラを null を使うように修正しています。
これに合わせて、96行目の optionvalue'none' から '' に変更してください: <option value=''>絞り込みなし</option>

  const [draftPaidByUserId, setDraftPaidByUserId] = useState<number | null>(
    selectedPaidByUserId ?? null,
  );

  useEffect(() => {
    if (!isOpen) return;
    setDraftBureauId(selectedBureauId);
    setDraftPaidByUserId(selectedPaidByUserId ?? null);
  }, [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 ?? '';

  const handleBureauChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const value = event.target.value;
    const nextBureauId = value === '' ? null : Number(value);
    setDraftBureauId(nextBureauId);
    setDraftPaidByUserId(null);
  };

  const handlePaidByChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const value = event.target.value;
    setDraftPaidByUserId(value === '' ? null : 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>
);
}
1 change: 1 addition & 0 deletions view/next-project/src/components/purchasereports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export { default as PurchaseOrderListModal } from './PurchaseOrderListModal';
export { default as PurchaseReportAddModal } from './PurchaseReportAddModal';
export { default as PurchaseReportConfirmModal } from './PurchaseReportConfirmModal';
export { default as PurchaseReportItemNumModal } from './PurchaseReportItemNumModal'; // "PurchaseReport|temNumModal"を修正しました。
export { default as PurchaseReportSummaryAmounts } from './PurchaseReportSummaryAmounts';
export { default as ReceiptModal } from './ReceiptModal';
export { default as CheckSettlementConfirmModal } from './CheckSettlementConfirmModal';
101 changes: 95 additions & 6 deletions view/next-project/src/pages/purchase_report_list/index.tsx
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';
Expand All @@ -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();
Expand All @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

useGetUsersフックからのデータ抽出ロジックが、型アサーションに依存しており、堅牢性に欠ける可能性があります。orvalで生成されたuseGetUsersの戻り値の型がvoidになっているようですが、実際には{ data: User[] }またはUser[]のようなデータが返却されていると推測されます。この不一致は、APIスキーマ定義の誤りやorvalの設定に起因する可能性があります。

現状のコードは型アサーション as User[] | { data?: User[] } | undefined を使ってこの問題を回避していますが、APIのレスポンス形式が想定外のものに変わった場合にランタイムエラーを引き起こす可能性があります。

根本的な解決としてはAPIスキーマやorvalの設定を見直すべきですが、コンポーネントレベルでの改善として、より安全な型ガードを使用するか、この実装のリスクについてコメントを残しておくことを推奨します。

}, [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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

selectedYear の初期値を useState で設定しようとしていますが、初回レンダリング時には yearPeriods が未定義の可能性があるため、この式は期待通りに動作せず、常に 0 が初期値となります。後続の useEffect で正しい値が設定されるため動作に問題はありませんが、コードの意図を明確にするために、useState の初期値は 0 に固定し、初期化は useEffect に任せるのが良いでしょう。

  const [selectedYear, setSelectedYear] = useState<number>(0);

);

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

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'>
Expand All @@ -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'>
金額
Expand All @@ -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) => (
Expand All @@ -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'
Expand All @@ -249,6 +336,7 @@ export default function PurchaseReports() {
}}
/>
</td>

<td className='px-4 py-2 text-center'>
<OpenCheckSettlementModalButton
id={report.id ?? 0}
Expand All @@ -260,6 +348,7 @@ export default function PurchaseReports() {
disabled={!sealChecks[report.id ?? 0]}
/>
</td>

<td>
<div className='flex'>
<div className='mx-1'>
Expand Down