Skip to content

Commit

Permalink
先番号割当の機能を追加
Browse files Browse the repository at this point in the history
  • Loading branch information
otiai10 committed Jan 12, 2025
1 parent 11248bd commit 1b43e5f
Show file tree
Hide file tree
Showing 12 changed files with 1,129 additions and 550 deletions.
110 changes: 110 additions & 0 deletions client/components/Uniforms/ModalContents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Dialog } from "@headlessui/react";
import Member from "../../models/Member";
import { PlayerNumber } from "../../models/PlayerNumber";
import { useState } from "react";
import { PlayerNumberRepo } from "../../repository/PlayerNumberRepo";

function QueryFieldView({ query, setQuery }: { query: string, setQuery: (q: string) => void }) {
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="名前やポジションでフィルタ"
className="w-full p-2 border border-gray-300 rounded-md mb-2"
/>
);
}

function filterfunc(m: Member, query: string): boolean {
if (m.slack.deleted) return false;
if (query === "") return true;
const q = query.toLowerCase();
if (m.slack.id.toLowerCase().includes(q)) return true;
if (m.slack.name.toLowerCase().includes(q)) return true;
if (m.slack.real_name.toLowerCase().includes(q)) return true;
if (m.slack.profile.display_name.toLowerCase().includes(q)) return true;
if (m.slack.profile.real_name.toLowerCase().includes(q)) return true;
if (m.slack.profile.title.toLowerCase().includes(q)) return true;
return false;
}

function MemberListView({
members, query, commit,
previousassign
}: {
members: { [slack_id: string]: Member }, query: string, commit: (m: Member, deprive?: boolean) => void,
previousassign?: Member;
}) {
return (
<div className="space-y-2 h-96 overflow-y-auto">
{Object.values(members).filter(m => filterfunc(m, query)).map((m) => (
<div key={m.slack.id} className="flex space-x-2 items-center">
<div>
<img src={m.slack.profile.image_512} className="w-8 rounded-sm"
alt={m.slack.real_name}
/>
</div>
<div className="flex-1">{m.slack.real_name}</div>
<div className="w-12 overflow-y-scroll whitespace-nowrap">{m.slack.profile.title}</div>
<div className="">
{previousassign?.slack?.id == m.slack.id ?
<button className="bg-red-400 text-white flex justify-center items-center py-1 px-2"
onClick={() => commit(m, true)}
>割当を外す</button>
:
<button className="bg-blue-200 text-white flex justify-center items-center py-1 px-2"
onClick={() => commit(m)}
>割り当てる</button>
}
</div>
</div>
))}
</div>
);
}

export function NumAssignModalContent({
close, members, playernumber,
previousassign,
loading,
}: {
close: () => void;
members: { [slack_id: string]: Member };
playernumber: PlayerNumber;
previousassign?: Member;
loading: { start: () => void, stop: () => void };
}) {
const [query, setQuery] = useState("");
return (
<div
className="inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl"
>
<Dialog.Title as="h3" className="font-medium leading-6 text-gray-900 mb-2">
<span>背番号</span> <span className="text-4xl text-red-900 border p-1">{playernumber.number}</span> <span>を選手に割り当てる</span>
</Dialog.Title>

<div>
<QueryFieldView query={query} setQuery={setQuery} />
<MemberListView members={members} query={query} previousassign={previousassign}
commit={async (member, deprive = false) => {
let message = `背番号${playernumber.number}を\n${member.slack.real_name}に割り当てますか?`;
if (previousassign) message += `\n\nこれにより、${previousassign.slack.real_name}から背番号${playernumber.number}の割り当てを外します。`;
if (deprive) message = `背番号${playernumber.number}の割り当てを${previousassign.slack.real_name}から外しますか?`;
if (!window.confirm(message)) return;
close();
loading.start();
const repo = new PlayerNumberRepo();
await repo.assign(playernumber, member.slack.id, deprive);
loading.stop();
// refresh the page data
location.reload();
}}
/>
</div>
<div>
<button onClick={close} className="mt-4 bg-red-200 text-gray-800 py-1 px-4 rounded-md">やっぱりやめる</button>
</div>
</div>
);
}
3 changes: 2 additions & 1 deletion client/components/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ label: 'Schedule', link: '/' },
// { label: 'Calendar', link: '/events' },
{ label: 'Equips', link: '/equips' },
{ label: 'Uniforms', link: '/uniforms' },
{ label: 'Team', link: '/members' },
];

Expand Down Expand Up @@ -45,7 +46,7 @@ export default function Layout({ children, myself, isLoading }: LayoutProps) {
const teamIcon: string = myself.team?.icon?.image_132 || defaultTeamIcon;
const teamName: string = myself.team?.name || defaultTeamName;
const myIcon: string = myself.slack.profile.image_512;
const title = `{process.env.NODE_ENV == "production" ? "" : "[DEV] "}{teamName} Team Hub`;
const title = `${process.env.NODE_ENV == "production" ? "" : "[DEV] "}${teamName} Team Hub`;
return (
<div id="root">
<Head>
Expand Down
25 changes: 25 additions & 0 deletions client/models/PlayerNumber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Member from "./Member";

export class PlayerNumber {
public player?: Member; // Populated field
constructor(
public number: number,
public uniforms: Uniform[],
public player_id: string, // Slack ID
) { }

static fromResponse(data: any) {
return data.map((p) => new PlayerNumber(p.number, p.uniforms, p.player_id));
}
}

export class Uniform {
public owner?: Member; // Populated field
constructor(
public number: number,
public size: string,
public color: boolean,
public damaged: boolean,
public owner_id: string, // Slack ID
) { }
}
122 changes: 122 additions & 0 deletions client/pages/uniforms/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from "react";
import Layout from "../../components/layout";
import { PlayerNumberRepo } from "../../repository/PlayerNumberRepo";
import { useRouter } from "next/router";
import { PlayerNumber } from "../../models/PlayerNumber";
import Member from "../../models/Member";
import { Dialog } from "@headlessui/react";
import { NumAssignModalContent } from "../../components/Uniforms/ModalContents";

async function listMembers(incdel: boolean): Promise<Member[]> {
const endpoint = process.env.API_BASE_URL + "/api/1/members";
const res = await fetch(endpoint + (incdel ? "?include_deleted=1" : ""));
return (await res.json()).map((m) => Member.fromAPIResponse(m));
}

function PlayerNumberListView({ playernumbers, members, loading }: {
playernumbers: PlayerNumber[];
members: { [slack_id: string]: Member };
loading: { start: () => void, stop: () => void };
}) {
const [modalContent, setModalContante] = useState<JSX.Element | null>(null);
return (
<div className="divide-y">
<div className="flex space-x-2 justify-center items-center">
<div className="w-8">#</div>
<div className="flex-1">ユニフォーム</div>
<div>割り当て</div>
</div>
{playernumbers.sort((p, n) => p.number > n.number ? 1 : -1).map((p) => (
<div key={p.number} className="flex space-x-2 justify-center items-center py-1">
<div className="w-8">{p.number}</div>
<div className="flex-1">
{(p.uniforms || []).map((u, i) => <div key={i}></div>)}
<button className="bg-gray-200 hover:bg-blue-200 text-white w-8 h-8 flex justify-center items-center">+</button>
</div>
<button className="bg-gray-200 hover:bg-blue-200 text-white rounded-full w-8 h-8 flex justify-center items-center"
onClick={() => setModalContante(<NumAssignModalContent
playernumber={p}
close={() => setModalContante(null)}
members={members}
loading={loading}
previousassign={members[p.player_id]}
/>)}
>
{p.player_id && members[p.player_id] ? <img src={members[p.player_id].slack.profile.image_512}
alt={members[p.player_id].slack.real_name}
/> : "+"}
</button>
</div>
))}
<Dialog
open={modalContent !== null}
as="div"
className="fixed inset-0 z-10 overflow-y-auto"
onClose={() => setModalContante(null)}
>
<div className="min-h-screen px-4 text-center">
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-40" />
{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block h-screen align-middle" aria-hidden="true">&#8203;</span>
{modalContent}
</div>
</Dialog>
</div>
);
}

function UniformClothesListView({ uniforms, members }: {
uniforms: any[];
members: { [slack_id: string]: Member };
}) {
return (
<div>
{uniforms.map((u) => (
<div key={u.id} className="flex space-x-2">
<div>{u.number}</div>
<div>{u.size}</div>
<div>{u.color}</div>
<div>{u.damaged}</div>
<div>{u.owner_id}</div>
</div>
))}
</div>
);
}

export default function Uniforms(props) {
const repo = useMemo(() => new PlayerNumberRepo(), []);
const [playernumbers, setPlayernumbers] = useState<PlayerNumber[]>([]);
const [members, setMembers] = useState<{[slack_id:string]:Member}>({})
useEffect(() => {
listMembers(true).then(mems => setMembers(mems.reduce((acc, mem) => ({...acc, [mem.slack.id]: mem}), {})));
repo.all().then(a => setPlayernumbers(PlayerNumber.fromResponse(a)));
}, [repo]);
const active = `shadow-inner rounded-t-lg bg-blue-600 text-white`;
const inactive = `shadow rounded-t-lg text-gray-600`;
const router = useRouter();
const hash = router.asPath.split("#")[1];
return (
<Layout {...props}>
<div className="flex space-x-2">
<div className={"flex-1 p-4 text-center cursor-pointer " + (hash !== "clothes" ? active : inactive)}
onClick={() => router.push("/uniforms#numbers")}
>背番号</div>
<div className={"flex-1 p-4 text-center cursor-pointer " + (hash === "clothes" ? active : inactive)}
// onClick={() => props.router.push("/uniforms")}
onClick={() => router.push("/uniforms#clothes")}
>ユニフォーム</div>
</div>
<div>
{hash === "clothes" ? <UniformClothesListView
uniforms={[]}
members={members}
/> : <PlayerNumberListView
playernumbers={playernumbers}
members={members}
loading={{ start: props.startLoading, stop: props.stopLoading }}
/>}
</div>
</Layout>
);
}
16 changes: 16 additions & 0 deletions client/repository/PlayerNumberRepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PlayerNumber } from "../models/PlayerNumber";

export class PlayerNumberRepo {
constructor(
public baseURL = process.env.API_BASE_URL,
) { }
all(): Promise<PlayerNumber[]> {
return fetch(`${this.baseURL}/api/1/numbers`)
.then((res) => res.json())
.then((json) => json.map((p) => new PlayerNumber(p.number, p.uniforms, p.player_id)));
}
assign(pn: PlayerNumber, player_id: string, deprive: boolean): Promise<Response> { // TODO: ちゃんとする
const endpoint = `${this.baseURL}/api/1/numbers/${pn.number}/${deprive ? "deprive" : "assign"}`;
return fetch(endpoint, {method: "POST", body: JSON.stringify({ player_id })});
}
}
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ func main() {
v1.Post("/equips/{id}/update", api.UpdateEquip)
v1.Post("/equips", api.CreateEquipItem)
v1.Get("/equips", api.ListEquips)
v1.Post("/numbers/{num}/assign", api.AssignPlayerNumber)
v1.Post("/numbers/{num}/deprive", api.DeprivePlayerNumber)
v1.Get("/numbers", api.GetAllNumbers)
r.Mount("/api/1", v1)

// Unauthorized pages
Expand Down Expand Up @@ -86,6 +89,7 @@ func main() {
r.With(page.Handle).Get("/equips/create", controllers.EquipCreate)
r.With(page.Handle).Get("/equips/report", controllers.EquipReport)
r.With(page.Handle).Get("/equips/{id}", controllers.Equip)
r.With(page.Handle).Get("/uniforms", controllers.Uniforms)
r.With(page.Handle).Get("/redirect/conditioning-form", controllers.RedirectConditioningForm)

// Cloud Tasks
Expand Down
Loading

0 comments on commit 1b43e5f

Please sign in to comment.