From 6cdbd91b86d921184330759c98e9e530dfecd7c6 Mon Sep 17 00:00:00 2001 From: nakatashingo <235711nakatashingo@gmail.com> Date: Wed, 20 May 2026 09:55:59 +0900 Subject: [PATCH 01/15] [feat] add endpoint to update user groups and corresponding request/response schemas --- openapi/openapi.yaml | 63 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 624aa0b4..ae915189 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2308,6 +2308,41 @@ paths: schema: type: object + /users/{user_id}/groups/{year}: + put: + tags: + - user + operationId: updateUserGroups + summary: ユーザー所属部門の差分更新 + description: userの所属部門を差分更新する。groupIds が空配列の場合は全削除する。 + parameters: + - name: user_id + in: path + description: userのid + required: true + schema: + type: integer + - name: year + in: path + description: 対象年度 + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/updateUserGroupsRequest" + responses: + "200": + description: 更新後のuser_groupsの状態を返す + content: + application/json: + schema: + $ref: "#/components/schemas/updateUserGroupsResponse" + x-codegen-request-body-name: updateUserGroupsRequest + /years: get: tags: @@ -3525,6 +3560,32 @@ components: - createdAt - updatedAt + updateUserGroupsRequest: + type: object + required: + - groupIds + properties: + groupIds: + type: array + description: userに紐付けるdivisionのID一覧。空配列の場合は全削除 + example: [1, 2, 3] + uniqueItems: true + items: + type: integer + + updateUserGroupsResponse: + type: object + required: + - groupIds + properties: + groupIds: + type: array + description: 更新後のuserに紐付いているdivisionのID一覧 + example: [1, 2, 3] + uniqueItems: true + items: + type: integer + ActivityStatus: type: string description: 活動ステータス @@ -3733,4 +3794,4 @@ components: - money - goods -x-original-swagger-version: "2.0" +x-original-swagger-version: "2.0" \ No newline at end of file From 56f3fecc376eb646e96295fe993a2e7b2b11edb0 Mon Sep 17 00:00:00 2001 From: nakatashingo <235711nakatashingo@gmail.com> Date: Wed, 20 May 2026 09:59:07 +0900 Subject: [PATCH 02/15] [feat] implement user groups update functionality with request/response models --- view/next-project/src/generated/hooks.ts | 81 +++++++++++++++++++ .../next-project/src/generated/model/index.ts | 2 + .../model/updateUserGroupsRequest.ts | 12 +++ .../model/updateUserGroupsResponse.ts | 12 +++ 4 files changed, 107 insertions(+) create mode 100644 view/next-project/src/generated/model/updateUserGroupsRequest.ts create mode 100644 view/next-project/src/generated/model/updateUserGroupsResponse.ts diff --git a/view/next-project/src/generated/hooks.ts b/view/next-project/src/generated/hooks.ts index 750fd899..c0096fe5 100644 --- a/view/next-project/src/generated/hooks.ts +++ b/view/next-project/src/generated/hooks.ts @@ -155,6 +155,8 @@ import type { Teacher, UpdateSponsorshipActivityRequest, UpdateSponsorshipActivityStatusRequest, + UpdateUserGroupsRequest, + UpdateUserGroupsResponse, User, YearPeriods, } from './model'; @@ -7084,6 +7086,85 @@ export const useDeleteUsersId = ( }; }; +/** + * userの所属部門を差分更新する。groupIds が空配列の場合は全削除する。 + * @summary ユーザー所属部門の差分更新 + */ +export type updateUserGroupsResponse200 = { + data: UpdateUserGroupsResponse; + status: 200; +}; + +export type updateUserGroupsResponseSuccess = updateUserGroupsResponse200 & { + headers: Headers; +}; +export type updateUserGroupsResponse = updateUserGroupsResponseSuccess; + +export const getUpdateUserGroupsUrl = (userId: number, year: number) => { + return `/users/${userId}/groups/${year}`; +}; + +export const updateUserGroups = async ( + userId: number, + year: number, + updateUserGroupsRequest: UpdateUserGroupsRequest, + options?: RequestInit, +): Promise => { + return customFetch(getUpdateUserGroupsUrl(userId, year), { + ...options, + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(updateUserGroupsRequest), + }); +}; + +export const getUpdateUserGroupsMutationFetcher = ( + userId: number, + year: number, + options?: SecondParameter, +) => { + return (_: Key, { arg }: { arg: UpdateUserGroupsRequest }) => { + return updateUserGroups(userId, year, arg, options); + }; +}; +export const getUpdateUserGroupsMutationKey = (userId: number, year: number) => + [`/users/${userId}/groups/${year}`] as const; + +export type UpdateUserGroupsMutationResult = NonNullable< + Awaited> +>; +export type UpdateUserGroupsMutationError = unknown; + +/** + * @summary ユーザー所属部門の差分更新 + */ +export const useUpdateUserGroups = ( + userId: number, + year: number, + options?: { + swr?: SWRMutationConfiguration< + Awaited>, + TError, + Key, + UpdateUserGroupsRequest, + Awaited> + > & { swrKey?: string }; + request?: SecondParameter; + }, +) => { + const { swr: swrOptions, request: requestOptions } = options ?? {}; + + const swrKey = swrOptions?.swrKey ?? getUpdateUserGroupsMutationKey(userId, year); + const swrFn = getUpdateUserGroupsMutationFetcher(userId, year, requestOptions); + + const query = useSWRMutation(swrKey, swrFn, swrOptions); + + return { + swrKey, + ...query, + }; +}; + /** * yearの一覧の取得 */ diff --git a/view/next-project/src/generated/model/index.ts b/view/next-project/src/generated/model/index.ts index a6313678..6b92e3bd 100644 --- a/view/next-project/src/generated/model/index.ts +++ b/view/next-project/src/generated/model/index.ts @@ -226,5 +226,7 @@ export * from './updateSponsorshipActivityRequest'; export * from './updateSponsorshipActivityRequestSponsorStyleDetailsItem'; export * from './updateSponsorshipActivityRequestSponsorStyleDetailsItemCategory'; export * from './updateSponsorshipActivityStatusRequest'; +export * from './updateUserGroupsRequest'; +export * from './updateUserGroupsResponse'; export * from './user'; export * from './yearPeriods'; diff --git a/view/next-project/src/generated/model/updateUserGroupsRequest.ts b/view/next-project/src/generated/model/updateUserGroupsRequest.ts new file mode 100644 index 00000000..4dcd5c4d --- /dev/null +++ b/view/next-project/src/generated/model/updateUserGroupsRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.18.0 🍺 + * Do not edit manually. + * NUTFes FinanSu API + * FinanSu APIドキュメント + * OpenAPI spec version: 2.0.0 + */ + +export interface UpdateUserGroupsRequest { + /** userに紐付けるdivisionのID一覧。空配列の場合は全削除 */ + groupIds: number[]; +} diff --git a/view/next-project/src/generated/model/updateUserGroupsResponse.ts b/view/next-project/src/generated/model/updateUserGroupsResponse.ts new file mode 100644 index 00000000..44835506 --- /dev/null +++ b/view/next-project/src/generated/model/updateUserGroupsResponse.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v7.18.0 🍺 + * Do not edit manually. + * NUTFes FinanSu API + * FinanSu APIドキュメント + * OpenAPI spec version: 2.0.0 + */ + +export interface UpdateUserGroupsResponse { + /** 更新後のuserに紐付いているdivisionのID一覧 */ + groupIds: number[]; +} From a1770f334d352456cb4618b7d411c71ea4e0f1f2 Mon Sep 17 00:00:00 2001 From: nakatashingo <235711nakatashingo@gmail.com> Date: Wed, 20 May 2026 10:35:03 +0900 Subject: [PATCH 03/15] [feat] enhance user edit modal with multi-select for divisions and update functionality --- .../src/components/users/EditModal.tsx | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/view/next-project/src/components/users/EditModal.tsx b/view/next-project/src/components/users/EditModal.tsx index 65a6f7c1..dc62771a 100644 --- a/view/next-project/src/components/users/EditModal.tsx +++ b/view/next-project/src/components/users/EditModal.tsx @@ -1,11 +1,39 @@ import { useRouter } from 'next/router'; -import React, { Dispatch, SetStateAction, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { RiArrowDropDownLine } from 'react-icons/ri'; +import { components as reactSelectComponents, DropdownIndicatorProps, StylesConfig } from 'react-select'; import { ROLES } from '@/constants/role'; +import { useGetDivisions, useGetDivisionsUsers, useUpdateUserGroups } from '@/generated/hooks'; import { put } from '@api/user'; -import { Modal, PrimaryButton, CloseButton, Input, Select } from '@components/common'; +import { CloseButton, Input, Modal, MultiSelect, PrimaryButton, Select } from '@components/common'; import { Bureau, User } from '@type/common'; +type DivisionOption = { value: string; label: string }; + +const divisionSelectStyles: StylesConfig = { + control: (base, state) => ({ + ...base, + borderRadius: '9999px', + borderColor: '#56daff', + boxShadow: state.isFocused ? '0 0 0 2px #56daff' : 'none', + '&:hover': { borderColor: '#56daff' }, + padding: '0 0.5rem', + minHeight: '2.5rem', + }), + dropdownIndicator: (base) => ({ + ...base, + padding: '0 0.75rem', + }), + indicatorSeparator: () => ({ display: 'none' }), +}; + +const DivisionDropdownIndicator = (props: DropdownIndicatorProps) => ( + + + +); + interface ModalProps { setShowModal: Dispatch>; id: number | string; @@ -15,8 +43,23 @@ interface ModalProps { export default function UserEditModal(props: ModalProps) { const router = useRouter(); + const currentYear = new Date().getFullYear(); + const userId = Number(props.id); const [formData, setFormData] = useState(props.user); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + + const { data: allDivisionsData } = useGetDivisions(); + const allDivisions = allDivisionsData?.data?.divisions ?? []; + + const { data: userDivisionsData } = useGetDivisionsUsers({ user_id: userId }); + + const { trigger: triggerUpdateGroups } = useUpdateUserGroups(userId, currentYear); + + useEffect(() => { + const currentIds = userDivisionsData?.data?.map((d) => d.divisionId) ?? []; + setSelectedGroupIds(currentIds); + }, [userDivisionsData]); const handler = (input: string) => @@ -29,10 +72,18 @@ export default function UserEditModal(props: ModalProps) { setFormData({ ...formData, [input]: e.target.value }); }; + const divisionOptions = allDivisions + .filter((d) => d.id !== undefined) + .map((d) => ({ value: String(d.id), label: d.name ?? '' })); + + const selectedOptions = divisionOptions.filter((opt) => + selectedGroupIds.includes(Number(opt.value)), + ); + const submitUser = async (data: User, id: number | string) => { const submitUserURL = process.env.CSR_API_URI + '/users/' + id; - console.log(data); await put(submitUserURL, data); + await triggerUpdateGroups({ groupIds: selectedGroupIds }); }; return ( @@ -70,11 +121,22 @@ export default function UserEditModal(props: ModalProps) { ))} +

部門

+
+ setSelectedGroupIds(opts.map((o) => Number(o.value)))} + placeholder='部門を選択' + customStyles={divisionSelectStyles} + components={{ DropdownIndicator: DivisionDropdownIndicator }} + /> +
{ - submitUser(formData, props.id); + onClick={async () => { + await submitUser(formData, props.id); router.reload(); }} > From 79f235fdd5baee28dc7df47a58666c239f1f7f41 Mon Sep 17 00:00:00 2001 From: nakatashingo <235711nakatashingo@gmail.com> Date: Fri, 22 May 2026 12:21:31 +0900 Subject: [PATCH 04/15] [feat] refactor user edit modal to use bureau-based division selection with checkboxes --- .../src/components/users/EditModal.tsx | 116 +++++++++++------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/view/next-project/src/components/users/EditModal.tsx b/view/next-project/src/components/users/EditModal.tsx index dc62771a..3d626097 100644 --- a/view/next-project/src/components/users/EditModal.tsx +++ b/view/next-project/src/components/users/EditModal.tsx @@ -1,39 +1,13 @@ import { useRouter } from 'next/router'; -import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; -import { RiArrowDropDownLine } from 'react-icons/ri'; -import { components as reactSelectComponents, DropdownIndicatorProps, StylesConfig } from 'react-select'; +import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'; +import { BUREAUS } from '@/constants/bureaus'; import { ROLES } from '@/constants/role'; import { useGetDivisions, useGetDivisionsUsers, useUpdateUserGroups } from '@/generated/hooks'; import { put } from '@api/user'; -import { CloseButton, Input, Modal, MultiSelect, PrimaryButton, Select } from '@components/common'; +import { CloseButton, Input, Modal, PrimaryButton, Select } from '@components/common'; import { Bureau, User } from '@type/common'; -type DivisionOption = { value: string; label: string }; - -const divisionSelectStyles: StylesConfig = { - control: (base, state) => ({ - ...base, - borderRadius: '9999px', - borderColor: '#56daff', - boxShadow: state.isFocused ? '0 0 0 2px #56daff' : 'none', - '&:hover': { borderColor: '#56daff' }, - padding: '0 0.5rem', - minHeight: '2.5rem', - }), - dropdownIndicator: (base) => ({ - ...base, - padding: '0 0.75rem', - }), - indicatorSeparator: () => ({ display: 'none' }), -}; - -const DivisionDropdownIndicator = (props: DropdownIndicatorProps) => ( - - - -); - interface ModalProps { setShowModal: Dispatch>; id: number | string; @@ -50,7 +24,6 @@ export default function UserEditModal(props: ModalProps) { const [selectedGroupIds, setSelectedGroupIds] = useState([]); const { data: allDivisionsData } = useGetDivisions(); - const allDivisions = allDivisionsData?.data?.divisions ?? []; const { data: userDivisionsData } = useGetDivisionsUsers({ user_id: userId }); @@ -72,13 +45,37 @@ export default function UserEditModal(props: ModalProps) { setFormData({ ...formData, [input]: e.target.value }); }; - const divisionOptions = allDivisions - .filter((d) => d.id !== undefined) - .map((d) => ({ value: String(d.id), label: d.name ?? '' })); + const divisionsByBureau = useMemo(() => { + const divisions = allDivisionsData?.data?.divisions ?? []; + const map = new Map(); + for (const division of divisions) { + if (!division.financialRecord) continue; + const list = map.get(division.financialRecord) ?? []; + list.push(division); + map.set(division.financialRecord, list); + } + const bureauOrder = new Map(BUREAUS.map((b, i) => [b.name, i])); + return Array.from(map.entries()).sort( + ([a], [b]) => (bureauOrder.get(a) ?? Infinity) - (bureauOrder.get(b) ?? Infinity), + ); + }, [allDivisionsData]); + + const toggleDivision = (id: number) => { + setSelectedGroupIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], + ); + }; - const selectedOptions = divisionOptions.filter((opt) => - selectedGroupIds.includes(Number(opt.value)), - ); + const toggleAllInBureau = (bureauName: string) => { + const ids = (divisionsByBureau.find(([name]) => name === bureauName)?.[1] ?? []) + .filter((d) => d.id !== undefined) + .map((d) => d.id as number); + setSelectedGroupIds((prev) => { + const allSelected = ids.every((id) => prev.includes(id)); + const others = prev.filter((id) => !ids.includes(id)); + return allSelected ? others : [...others, ...ids]; + }); + }; const submitUser = async (data: User, id: number | string) => { const submitUserURL = process.env.CSR_API_URI + '/users/' + id; @@ -122,15 +119,44 @@ export default function UserEditModal(props: ModalProps) {

部門

-
- setSelectedGroupIds(opts.map((o) => Number(o.value)))} - placeholder='部門を選択' - customStyles={divisionSelectStyles} - components={{ DropdownIndicator: DivisionDropdownIndicator }} - /> +
+ {divisionsByBureau.map(([bureauName, divisions]) => { + const ids = divisions + .filter((d) => d.id !== undefined) + .map((d) => d.id as number); + const allSelected = ids.length > 0 && ids.every((id) => selectedGroupIds.includes(id)); + return ( +
+
+ {bureauName} + +
+
+ {divisions.map((division) => + division.id !== undefined ? ( + + ) : null, + )} +
+
+ ); + })}
From 6d6b87d97c38b5ea32472f3d734e102e5b97488e Mon Sep 17 00:00:00 2001 From: nakatashingo <235711nakatashingo@gmail.com> Date: Fri, 22 May 2026 12:22:15 +0900 Subject: [PATCH 05/15] [feat] simplify division selection layout in user edit modal --- view/next-project/src/components/users/EditModal.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/view/next-project/src/components/users/EditModal.tsx b/view/next-project/src/components/users/EditModal.tsx index 3d626097..fe05b5a3 100644 --- a/view/next-project/src/components/users/EditModal.tsx +++ b/view/next-project/src/components/users/EditModal.tsx @@ -119,11 +119,9 @@ export default function UserEditModal(props: ModalProps) {

部門

-
+
{divisionsByBureau.map(([bureauName, divisions]) => { - const ids = divisions - .filter((d) => d.id !== undefined) - .map((d) => d.id as number); + const ids = divisions.filter((d) => d.id !== undefined).map((d) => d.id as number); const allSelected = ids.length > 0 && ids.every((id) => selectedGroupIds.includes(id)); return (
@@ -140,10 +138,7 @@ export default function UserEditModal(props: ModalProps) {
{divisions.map((division) => division.id !== undefined ? ( -