diff --git a/mysql/seed/000001_initial_schema_seed.sql b/mysql/seed/000001_initial_schema_seed.sql index 47dfec645..695137f5c 100644 --- a/mysql/seed/000001_initial_schema_seed.sql +++ b/mysql/seed/000001_initial_schema_seed.sql @@ -78,7 +78,11 @@ INSERT INTO VALUES (1, 'payment-receipts', 'receipt-1.jpg', 'image/jpeg', ''), (2, 'payment-receipts', 'receipt-2.jpg', 'image/jpeg', ''), - (3, 'payment-receipts', 'receipt-3.jpg', 'image/jpeg', ''); + (3, 'payment-receipts', 'receipt-3.jpg', 'image/jpeg', ''), + (4, 'payment-receipts', 'receipt-4.jpg', 'image/jpeg', ''), + (5, 'payment-receipts', 'receipt-5.jpg', 'image/jpeg', ''), + (6, 'payment-receipts', 'receipt-6.jpg', 'image/jpeg', ''), + (7, 'payment-receipts', 'receipt-7.jpg', 'image/jpeg', ''); INSERT INTO buy_statuses (buy_report_id, is_packed, is_settled) diff --git a/view/next-project/src/components/purchasereports/PurchaseReportPaidByFilterModal.tsx b/view/next-project/src/components/purchasereports/PurchaseReportPaidByFilterModal.tsx new file mode 100644 index 000000000..86c6e769e --- /dev/null +++ b/view/next-project/src/components/purchasereports/PurchaseReportPaidByFilterModal.tsx @@ -0,0 +1,177 @@ +import React, { FC, useMemo, useState } from 'react'; +import Select, { SingleValue } from 'react-select'; + +import { normalizePaidBy } from '@/utils/purchaseReportFilters'; +import { + CloseButton, + Modal, + OutlinePrimaryButton, + Select as CommonSelect, +} from '@components/common'; +import { Bureau, User } from '@type/common'; + +type NameOption = { value: string; label: string }; + +interface PurchaseReportPaidByFilterModalProps { + onClose: () => void; + onApply: (selection: { + bureauId: number | null; + paidByUserId: number | null; + paidBy: string | null | undefined; + }) => void; + bureaus: Bureau[]; + users: User[]; + legacyPaidByOptions: string[]; + selectedBureauId: number | null; + selectedPaidByUserId: number | null; + selectedPaidBy: string | null | undefined; +} + +const PurchaseReportPaidByFilterModal: FC = (props) => { + const { + onClose, + onApply, + bureaus, + users, + legacyPaidByOptions, + selectedBureauId, + selectedPaidByUserId, + selectedPaidBy, + } = props; + + const [draftBureauId, setDraftBureauId] = useState(selectedBureauId); + const [draftPaidByUserId, setDraftPaidByUserId] = useState( + selectedPaidByUserId ?? null, + ); + const [draftPaidBy, setDraftPaidBy] = useState(normalizePaidBy(selectedPaidBy)); + + const labelClassName = 'mb-2 text-sm text-black-600 [font-family:"Noto_Sans_JP"]'; + const selectTextClassName = 'text-black-600 [font-family:"Noto_Sans_JP"]'; + const optionClassName = 'text-black-600 [font-family:"Noto_Sans_JP"]'; + + 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 nameOptions = useMemo( + (): NameOption[] => [ + { value: '', label: '絞り込みなし' }, + ...legacyPaidByOptions.map((name) => ({ value: `legacy:${name}`, label: name })), + ...filteredUsers.map((u) => { + const bureauName = bureauNameMap.get(u.bureauID); + const label = draftBureauId || !bureauName ? u.name : `${bureauName} ${u.name}`; + return { value: `user:${u.id}`, label }; + }), + ], + [filteredUsers, legacyPaidByOptions, bureauNameMap, draftBureauId], + ); + + const nameSelectValue = + draftPaidByUserId != null + ? (nameOptions.find((o) => o.value === `user:${draftPaidByUserId}`) ?? nameOptions[0]) + : draftPaidBy != null + ? (nameOptions.find((o) => o.value === `legacy:${draftPaidBy}`) ?? nameOptions[0]) + : nameOptions[0]; + + const handleBureauChange = (event: React.ChangeEvent) => { + const value = event.target.value; + const nextBureauId = value === '' ? null : Number(value); + setDraftBureauId(nextBureauId); + setDraftPaidByUserId(null); + setDraftPaidBy(null); + }; + + const handleNameChange = (option: SingleValue) => { + const val = option?.value ?? ''; + if (val === '') { + setDraftPaidByUserId(null); + setDraftPaidBy(null); + } else if (val.startsWith('user:')) { + const id = Number(val.slice(5)); + setDraftPaidByUserId(id); + setDraftPaidBy(users.find((u) => u.id === id)?.name ?? null); + } else { + setDraftPaidByUserId(null); + setDraftPaidBy(val.slice(7)); + } + }; + + const handleApply = () => { + onApply({ + bureauId: draftBureauId ?? null, + paidByUserId: draftPaidByUserId ?? null, + paidBy: normalizePaidBy(draftPaidBy), + }); + }; + + return ( + +
+ +
+
+
+

局名

+ + + {bureaus.map((bureau) => ( + + ))} + +
+
+

氏名

+ + instanceId='paid-by-name-select' + isSearchable + options={nameOptions} + value={nameSelectValue} + onChange={handleNameChange} + noOptionsMessage={() => '該当なし'} + menuPortalTarget={typeof document !== 'undefined' ? document.body : null} + styles={{ + control: (base, state) => ({ + ...base, + borderRadius: '9999px', + borderColor: state.isFocused ? '#48b2cf' : '#56DAFF', + outline: state.isFocused ? '1.5px #48b2cf solid' : 'none', + boxShadow: 'none', + paddingTop: '0.25rem', + paddingBottom: '0.25rem', + paddingLeft: '0.75rem', + paddingRight: '0.25rem', + '&:hover': { + borderColor: state.isFocused ? '#48b2cf' : '#56DAFF', + }, + }), + indicatorSeparator: () => ({ display: 'none' }), + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + }} + /> +
+
+
+ 絞り込む +
+
+ ); +}; + +export default PurchaseReportPaidByFilterModal; diff --git a/view/next-project/src/components/purchasereports/PurchaseReportSummaryAmounts.tsx b/view/next-project/src/components/purchasereports/PurchaseReportSummaryAmounts.tsx new file mode 100644 index 000000000..2f90ee9a4 --- /dev/null +++ b/view/next-project/src/components/purchasereports/PurchaseReportSummaryAmounts.tsx @@ -0,0 +1,31 @@ +interface PurchaseReportSummaryAmountsProps { + unsettledAmountText: string; + unpackedAmountText: string; + className?: string; +} + +export default function PurchaseReportSummaryAmounts({ + unsettledAmountText, + unpackedAmountText, + className = '', +}: PurchaseReportSummaryAmountsProps) { + return ( +
+
+ 未清算金額 + + + {unsettledAmountText} + + + + 未封詰め金額 + + + {unpackedAmountText} + + +
+
+ ); +} diff --git a/view/next-project/src/components/purchasereports/index.ts b/view/next-project/src/components/purchasereports/index.ts index eca7bd61e..ecd14bde4 100644 --- a/view/next-project/src/components/purchasereports/index.ts +++ b/view/next-project/src/components/purchasereports/index.ts @@ -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'; diff --git a/view/next-project/src/pages/purchase_report_list/index.tsx b/view/next-project/src/pages/purchase_report_list/index.tsx index 340e26498..507d1fe37 100644 --- a/view/next-project/src/pages/purchase_report_list/index.tsx +++ b/view/next-project/src/pages/purchase_report_list/index.tsx @@ -1,18 +1,24 @@ import { saveAs } from 'file-saver'; import { useRouter } from 'next/router'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { RiArrowDropDownLine } from 'react-icons/ri'; import { TbDownload } from 'react-icons/tb'; 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'; import { useCurrentUser } from '@/store'; +import { buildPaidByFilterParams } from '@/utils/purchaseReportFilters'; import { Card, Checkbox, EditButton, Loading, Title } from '@components/common'; import MainLayout from '@components/layout/MainLayout'; import OpenDeleteModalButton from '@components/purchasereports/OpenDeleteModalButton'; @@ -20,8 +26,16 @@ import OpenDeleteModalButton from '@components/purchasereports/OpenDeleteModalBu import type { BuyReportDetail, GetBuyReportsDetailsParams, + GetBuyReportsSummaryParams, PutBuyReportStatusBuyReportIdBody, } from '@/generated/model'; +import type { User } from '@type/common'; + +const isUser = (value: unknown): value is User => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return typeof candidate.id === 'number' && typeof candidate.name === 'string'; +}; export default function PurchaseReports() { const router = useRouter(); @@ -31,20 +45,46 @@ export default function PurchaseReports() { error: yearPeriodsError, } = useGetYearsPeriods(); const yearPeriods = yearPeriodsData?.data; + const user = useCurrentUser(); - const [selectedYear, setSelectedYear] = useState( - yearPeriods && yearPeriods.length > 0 ? yearPeriods[yearPeriods.length - 1].year : 0, - ); + + const { data: usersResponse } = useGetUsers(); + const users = useMemo(() => { + const responseData: unknown = usersResponse?.data; + if (Array.isArray(responseData)) return responseData.filter(isUser); + if (responseData && typeof responseData === 'object') { + const nested = (responseData as { data?: unknown }).data; + if (Array.isArray(nested)) return nested.filter(isUser); + } + return []; + }, [usersResponse]); user?.roleID === 1 && router.push('/my_page'); + const [selectedYear, setSelectedYear] = useState(0); + useEffect(() => { if (yearPeriods && yearPeriods.length > 0) { const latestYear = Math.max(...yearPeriods.map((period) => period.year)); setSelectedYear(latestYear); } }, [yearPeriods]); - const getBuyReportsDetailsParams: GetBuyReportsDetailsParams = { year: selectedYear }; + + const [isPaidByFilterOpen, setIsPaidByFilterOpen] = useState(false); + const [selectedBureauId, setSelectedBureauId] = useState(null); + const [selectedPaidBy, setSelectedPaidBy] = useState(undefined); + const [selectedPaidByUserId, setSelectedPaidByUserId] = useState(null); + + const paidByFilterParams = buildPaidByFilterParams({ + paidByUserId: selectedPaidByUserId, + paidBy: selectedPaidBy, + }); + + const getBuyReportsDetailsParams: GetBuyReportsDetailsParams = { + year: selectedYear, + ...(selectedBureauId != null ? { financial_record_id: selectedBureauId } : {}), + ...paidByFilterParams, + }; const { data: buyReportsData, @@ -52,35 +92,36 @@ export default function PurchaseReports() { error: buyReportsError, mutate: mutateBuyReportData, } = useGetBuyReportsDetails(getBuyReportsDetailsParams); + const buyReports = useMemo(() => buyReportsData?.data ?? [], [buyReportsData]); - const paidByUserIds = useMemo( - () => [ - ...new Set( - buyReports.map((report) => report.paidByUserId).filter((id): id is number => !!id), - ), - ], - [buyReports], - ); + const userNameMap = useMemo(() => Object.fromEntries(users.map((u) => [u.id, u.name])), [users]); - const userParams = useMemo( - () => (paidByUserIds.length > 0 ? { ids: paidByUserIds } : undefined), - [paidByUserIds], - ); + const legacyPaidByOptions = useMemo(() => { + const userNames = new Set(users.map((u) => u.name)); + const seen = new Set(); + return buyReports + .map((r) => r.paidBy) + .filter( + (name): name is string => + !!name && !userNames.has(name) && !seen.has(name) && !!seen.add(name), + ); + }, [buyReports, users]); - const { data: userData } = useGetUsers(userParams, { - swr: { - enabled: !!userParams, - }, - }); + const getBuyReportsSummaryParams: GetBuyReportsSummaryParams = { + year: selectedYear, + ...(selectedBureauId != null ? { financial_record_id: selectedBureauId } : {}), + ...paidByFilterParams, + }; - const userNameMap = useMemo( - () => - Object.fromEntries( - (userData?.data ?? []).map((targetUser) => [targetUser.id, targetUser.name]), - ), - [userData], - ); + const { + data: buyReportsSummaryData, + isLoading: isBuyReportsSummaryLoading, + error: buyReportsSummaryError, + mutate: mutateBuyReportsSummary, + } = useGetBuyReportsSummary(getBuyReportsSummaryParams, { + swr: { enabled: selectedYear > 0 }, + }); const [sealChecks, setSealChecks] = useState>({}); const [settlementChecks, setSettlementChecks] = useState>({}); @@ -136,6 +177,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); @@ -156,10 +209,20 @@ export default function PurchaseReports() { try { await trigger(putBuyReportStatusBuyReportIdBody); + mutateBuyReportData(); + mutateBuyReportsSummary(); } catch { console.error('Failed to update buy_reports:', statusError); } - }, [buyReportId, sealChecks, settlementChecks, trigger, statusError]); + }, [ + buyReportId, + sealChecks, + settlementChecks, + trigger, + statusError, + mutateBuyReportData, + mutateBuyReportsSummary, + ]); useEffect(() => { updateStatus(); @@ -167,7 +230,8 @@ export default function PurchaseReports() { const onSuccess = useCallback(() => { mutateBuyReportData(); - }, [mutateBuyReportData]); + mutateBuyReportsSummary(); + }, [mutateBuyReportData, mutateBuyReportsSummary]); const downloadCSV = async () => { const url = `${process.env.CSR_API_URI}/buy_reports/csv/download?year=${selectedYear}`; @@ -210,8 +274,31 @@ export default function PurchaseReports() { CSVダウンロード + + + {isPaidByFilterOpen && ( + setIsPaidByFilterOpen(false)} + onApply={({ bureauId, paidByUserId, paidBy }) => { + setSelectedBureauId(bureauId); + setSelectedPaidByUserId(paidByUserId ?? null); + setSelectedPaidBy(paidBy); + setIsPaidByFilterOpen(false); + }} + bureaus={BUREAUS} + users={users} + legacyPaidByOptions={legacyPaidByOptions} + selectedBureauId={selectedBureauId} + selectedPaidByUserId={selectedPaidByUserId} + selectedPaidBy={selectedPaidBy} + /> + )} +
@@ -230,7 +317,17 @@ export default function PurchaseReports() { 物品 + {buyReports && buyReports.length > 0 ? ( buyReports.map((report) => ( @@ -268,6 +366,7 @@ export default function PurchaseReports() { + + +
- 立替者 +
+ 立替者 + +
金額 @@ -244,6 +341,7 @@ export default function PurchaseReports() {
{formatAmount(report.amount ?? 0)}
diff --git a/view/next-project/src/utils/purchaseReportFilters.ts b/view/next-project/src/utils/purchaseReportFilters.ts new file mode 100644 index 000000000..f17286dd3 --- /dev/null +++ b/view/next-project/src/utils/purchaseReportFilters.ts @@ -0,0 +1,34 @@ +export type PaidByFilterInput = { + paidByUserId?: number | null; + paidBy?: string | null | undefined; +}; + +export type PaidByFilterParams = { + paid_by_user_id?: number; + paid_by?: string; +}; + +export const normalizePaidBy = (value: string | null | undefined): string | null => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const normalizePaidByUserId = (value: number | null | undefined): number | null => { + if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) return null; + return value; +}; + +export const buildPaidByFilterParams = (input: PaidByFilterInput): PaidByFilterParams => { + const paidByUserId = normalizePaidByUserId(input.paidByUserId); + if (paidByUserId != null) { + return { paid_by_user_id: paidByUserId }; + } + + const paidBy = normalizePaidBy(input.paidBy); + if (paidBy != null) { + return { paid_by: paidBy }; + } + + return {}; +};