Add /fiches

This commit is contained in:
pikiou 2022-06-25 02:12:25 +02:00
parent 88549bf42d
commit 3a5f2792c9
26 changed files with 601 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -12,11 +12,19 @@ import LogoutButton from "../components/LogoutButton/LogoutButton"
interface Route {
route: { routes: RouteConfig[] }
location: Location
}
export const reactAppId = "react-view"
const App = ({ route }: Route): JSX.Element => (
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
const App = ({ route, location }: Route): JSX.Element => {
if (location.pathname === "/fiches") {
return <div className={styles.cardPage}>{renderRoutes(route.routes)}</div>
}
// else
return (
<div>
<Helmet {...config.APP}>
<meta
@ -44,5 +52,6 @@ const App = ({ route }: Route): JSX.Element => (
<ToastContainer />
</div>
)
}
export default App

View File

@ -2,6 +2,13 @@
@import "../theme/mixins";
@import "../theme/main";
.cardPage {
background-color: $color-white;
// overflow: auto;
display: table;
width: 100vw;
}
.header {
position: relative;
margin: 10px 0 20px;

View File

@ -0,0 +1,167 @@
import React, { memo } from "react"
import { useSelector } from "react-redux"
import styles from "./styles.module.scss"
// import styles from "./styles.module.scss"
import { fetchBoxListIfNeed, selectContainerSortedDetailedBoxes } from "../../store/boxList"
import {
fetchVolunteerDetailedKnowledgeListIfNeed,
selectVolunteerDetailedKnowledgeList,
} from "../../store/volunteerDetailedKnowledgeList"
import { DetailedBox } from "../../services/boxes"
import { VolunteerDetailedKnowledge } from "../../services/volunteers"
const KnowledgeCard: React.FC = (): JSX.Element | null => {
const detailedBoxes = useSelector(selectContainerSortedDetailedBoxes) as DetailedBox[]
const volunteerDetailedKnowledgeList = useSelector(selectVolunteerDetailedKnowledgeList)
return <>{detailedBoxes.map((box) => boxElement(box, volunteerDetailedKnowledgeList))}</>
}
const boxElement = (
box: DetailedBox,
volunteerDetailedKnowledgeList: VolunteerDetailedKnowledge[]
): JSX.Element => {
const playerCount: string =
box.playersMin === box.playersMax
? `${box.playersMin}`
: `${box.playersMin} à ${box.playersMax}`
const typeStyle = {
"": null,
Ambiance: styles.verteImg,
Famille: styles.orangeImg,
Expert: styles.rougeImg,
}[box.type]
const year = new Date().getFullYear()
const okVolunteers = wiseVolunteers(volunteerDetailedKnowledgeList, box.gameId, "ok")
const bofVolunteers = wiseVolunteers(volunteerDetailedKnowledgeList, box.gameId, "bof")
const someOk = okVolunteers.length > 0
const someBof = bofVolunteers.length > 0
const some = someOk || someBof
return (
<div key={box.id} className={styles.card}>
<header className={styles.header}>{box.title}</header>
<div className={styles.showUnknownOnlyLabeldetail}>
<div className={styles.masteryContainer}>
<table>
<tbody>
<tr>
<td className={styles.imageContainer}>
<img
className={styles.gameImage}
src={box.bggPhoto}
alt="Board game box"
/>
</td>
<td className={styles.tableBenevoles}>
<table className={styles.benevolesContainer}>
<tbody>
<tr className={styles.okHeader}>
<td>Ils maîtrisent</td>
</tr>
<tr className={styles.listOk}>
<td>
<div className={styles.nicknameContainer}>
{okVolunteers}
{!some && (
<>
Désolé aucun bénévole n'y a joué, il
va falloir lire la règle.
</>
)}
{!someOk && someBof && (
<>
Aucun bénévole ne maîtrise les
règles de ce jeu.
</>
)}
</div>
</td>
</tr>
{someBof && (
<tr className={styles.bofHeader}>
<td>Ils connaissent</td>
</tr>
)}
{someBof && (
<tr className={styles.listBof}>
<td>
<div className={styles.nicknameContainer}>
{bofVolunteers}
</div>
</td>
</tr>
)}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<div className={styles.gamedetailContainer}>
<table>
<tbody>
<tr>
<td className={styles.numberOfPlayersImgContainer}>
<div className={styles.numberOfPlayersImg} />
</td>
<td className={styles.numberOfPlayers}>{playerCount}</td>
<td className={styles.durationImgContainer}>
<div className={styles.durationImg} />
</td>
<td className={styles.duration}>{box.duration} min</td>
<td className={styles.typeImgContainer}>
<div className={typeStyle} />
</td>
<td className={styles.type}>{box.type}</td>
<td className={styles.pppImgContainer}>
<div className={styles.pppImg} />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<footer className={styles.footer}>
<div className={styles.year}>{year}</div>
<div className={styles.container}>{box.container}</div>
</footer>
</div>
)
}
function wiseVolunteers(
volunteersKnowledge: VolunteerDetailedKnowledge[],
gameId: number,
wiseness: "ok" | "bof"
): JSX.Element[] {
return volunteersKnowledge
.filter(
(v) =>
v[wiseness].includes(gameId) &&
(v.dayWishes.includes("S") || v.dayWishes.includes("D"))
)
.map((v) => (
<div key={v.id} className={styles.nickname}>
<b>{v.nickname.charAt(0).toUpperCase()}</b>
{v.nickname.substring(1)}
{v.dayWishes.includes("S") && !v.dayWishes.includes("D") && (
<span className={styles.oneDayOnly}>(sam.)</span>
)}
{!v.dayWishes.includes("S") && v.dayWishes.includes("D") && (
<span className={styles.oneDayOnly}>(dim.)</span>
)}
</div>
))
}
export default memo(KnowledgeCard)
export const fetchFor = [fetchBoxListIfNeed, fetchVolunteerDetailedKnowledgeListIfNeed]

View File

@ -122,3 +122,181 @@
background-color: $color-active-niet;
}
}
/* Cards */
.card {
display: inline-block;
vertical-align: top;
width: 48vw;
margin: 1vw;
break-inside: avoid;
border: $color-black solid 0.1vw;
}
.header {
color: $color-white;
background-color: #e18502;
text-align: center;
font-size: 2.5vw;
line-height: 5vw;
font-family: $font-pel;
}
.imageContainer {
padding: 1vw;
}
.benevolesContainer {
width: 100%;
}
.tableBenevoles {
width: 100%;
vertical-align: top;
padding-top: 1vw;
}
.gameImage {
width: 10vw;
}
.okHeader,
.bofHeader {
text-align: center;
width: 100%;
font-size: 1.8vw;
font-weight: bold;
}
.okHeader {
background-color: #9dba5d;
}
.listOk {
text-align: center;
}
.listOk td {
padding-top: 15px;
padding-bottom: 15px;
}
.bofHeader {
background-color: #cb9902;
}
.listBof {
text-align: center;
}
.listBof td {
padding-top: 15px;
padding-bottom: 15px;
}
.nicknameContainer {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
align-content: center;
gap: 0.5vw;
}
.nickname {
flex: 1 1 auto;
white-space: nowrap;
}
.numberOfPlayersImgContainer {
width: 6.3vw;
}
.numberOfPlayersImg {
width: 6vw;
height: 4vw;
background: url("../../app/img/knowledgeCards/nombre_joueurs-fiche.png") no-repeat center center;
background-size: cover;
}
.numberOfPlayers {
font-family: $font-pel;
font-size: 1.6vw;
white-space: nowrap;
}
.durationImgContainer {
width: 6.1vw;
padding-left: 1vw;
}
.durationImg {
width: 6vw;
height: 4vw;
background: url("../../app/img/knowledgeCards/duree-fiche.png") no-repeat center center;
background-size: cover;
}
.duration {
font-family: $font-pel;
font-size: 1.6vw;
white-space: nowrap;
}
.typeImgContainer {
width: 6.2vw;
padding-left: 1.1vw;
}
.verteImg,
.orangeImg,
.rougeImg {
width: 6vw;
height: 6vw;
background-size: cover;
}
.verteImg {
background: url("../../app/img/knowledgeCards/jauge-verte.png") no-repeat center center;
}
.orangeImg {
background: url("../../app/img/knowledgeCards/jauge-orange.png") no-repeat center center;
}
.rougeImg {
background: url("../../app/img/knowledgeCards/jauge-rouge.png") no-repeat center center;
}
.type {
font-family: $font-pel;
font-size: 1.6vw;
white-space: nowrap;
}
.pppImgContainer {
width: 6vw;
padding-left: 0.7vw;
}
.pppImg {
width: 6vw;
height: 3.4vw;
background: url("../../app/img/knowledgeCards/ppp-fiche-ko-trespetit.png") no-repeat center
center;
background-size: cover;
}
.oneDayOnly {
color: $color-red;
}
.footer {
height: 3vw;
font-family: $font-pel;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
align-content: center;
background-color: #96b397;
font-style: oblique;
}
.year {
flex: 0 1 auto;
align-self: center;
margin-left: 1vw;
font-size: 1vw;
}
.container {
flex: 0 1 auto;
align-self: center;
margin-right: 1vw;
font-size: 1.5vw;
}

View File

@ -10,6 +10,7 @@ import GameList from "./GameList"
import Loading from "./Loading"
import LoginForm from "./LoginForm"
import BoxList, { fetchFor as fetchForKnowledge } from "./Knowledge/BoxList"
import KnowledgeCard, { fetchFor as fetchForKnowledgeCard } from "./Knowledge/KnowledgeCard"
import KnowledgeIntro from "./Knowledge/KnowledgeIntro"
import Asks, { fetchFor as fetchForAsks } from "./Asks"
import ParticipationDetailsForm, {
@ -34,6 +35,8 @@ export {
fetchForBoard,
BoxList,
fetchForKnowledge,
KnowledgeCard,
fetchForKnowledgeCard,
DayWishesForm,
fetchForDayWishesForm,
ErrorBoundary,

View File

@ -0,0 +1,23 @@
import { FC, memo } from "react"
import { useSelector } from "react-redux"
import { RouteComponentProps } from "react-router-dom"
import { AppThunk } from "../../store"
import { KnowledgeCard, fetchForKnowledgeCard } from "../../components"
import { selectUserJwtToken } from "../../store/auth"
export type Props = RouteComponentProps
const KnowledgeCardsPage: FC<Props> = (): JSX.Element => {
const jwtToken = useSelector(selectUserJwtToken)
if (jwtToken === undefined) return <p>Loading...</p>
if (!jwtToken) {
return <div>Besoin d'être identifié</div>
}
return <KnowledgeCard />
}
// Fetch server-side data here
export const loadData = (): AppThunk[] => [...fetchForKnowledgeCard.map((f) => f())]
export default memo(KnowledgeCardsPage)

View File

@ -0,0 +1,16 @@
import loadable from "@loadable/component"
import { Loading, ErrorBoundary } from "../../components"
import { Props, loadData } from "./KnowledgeCardsPage"
const Knowledges = loadable(() => import("./KnowledgeCardsPage"), {
fallback: <Loading />,
})
export default (props: Props): JSX.Element => (
<ErrorBoundary>
<Knowledges {...props} />
</ErrorBoundary>
)
export { loadData }

View File

@ -0,0 +1,9 @@
@import "../../theme/mixins";
.knowledgesPage {
@include page-wrapper-center;
}
.knowledgesContent {
@include page-content-wrapper(700px);
}

View File

@ -10,6 +10,7 @@ import AsyncAnnouncements, { loadData as loadAnnouncementsData } from "../pages/
import AsyncTeamAssignment, { loadData as loadTeamAssignmentData } from "../pages/TeamAssignment"
import AsyncRegisterPage, { loadData as loadRegisterPage } from "../pages/Register"
import AsyncKnowledge, { loadData as loadKnowledgeData } from "../pages/Knowledge"
import AsyncKnowledgeCards, { loadData as loadCardKnowledgeData } from "../pages/KnowledgeCards"
import AsyncTeams, { loadData as loadTeamsData } from "../pages/Teams"
import AsyncBoard, { loadData as loadBoardData } from "../pages/Board"
import AsyncVolunteers, { loadData as loadVolunteersData } from "../pages/Volunteers"
@ -43,6 +44,12 @@ export default [
component: AsyncKnowledge,
loadData: loadKnowledgeData,
},
{
path: "/fiches",
component: AsyncKnowledgeCards,
loadData: loadCardKnowledgeData,
meh: "doh",
},
{
path: "/preRegister",
component: AsyncRegisterPage,

View File

@ -14,6 +14,7 @@ export const detailedBoxListGet = expressAccessor.get(async (list) => {
}
return list
.filter((box) => box)
.filter((box) => !box.unplayable)
.map((box) => {
const game = gameList.find((g) => g.id === box.gameId)
@ -27,6 +28,11 @@ export const detailedBoxListGet = expressAccessor.get(async (list) => {
bggPhoto: game.bggPhoto,
poufpaf: game.poufpaf,
bggId: game.bggId,
container: box.container,
playersMin: game.playersMin,
playersMax: game.playersMax,
duration: game.duration,
type: game.type,
} as DetailedBox
})
})

View File

@ -13,14 +13,7 @@ export const gameListGet = expressAccessor.listGet()
// export const gameAdd = expressAccessor.add()
// export const gameSet = expressAccessor.set()
export const gameDetailsUpdate = expressAccessor.listSet(async (list, _body, _id, roles) => {
if (!roles.includes("admin")) {
throw Error(
`À moins d'être admin, on ne peut pas modifier n'importe quel jeu, ${JSON.stringify(
roles
)}`
)
}
export const gameDetailsUpdate = expressAccessor.listSet(async (list) => {
const newList = cloneDeep(list)
// TODO update game list details from BGG

View File

@ -1,6 +1,6 @@
import path from "path"
import * as fs from "fs"
import { assign, cloneDeep, max, omit, pick } from "lodash"
import { assign, cloneDeep, max, omit, pick, remove } from "lodash"
import bcrypt from "bcrypt"
import sgMail from "@sendgrid/mail"
@ -18,6 +18,7 @@ import {
VolunteerParticipationDetails,
VolunteerTeamAssign,
VolunteerKnowledge,
VolunteerDetailedKnowledge,
VolunteerPersonalInfo,
} from "../../services/volunteers"
import { canonicalEmail, canonicalMobile, trim, validMobile } from "../../utils/standardization"
@ -533,3 +534,34 @@ export const volunteerKnowledgeSet = expressAccessor.set(async (list, body, id)
} as VolunteerKnowledge,
}
})
export const volunteerDetailedKnowledgeList = expressAccessor.get(async (list) => {
const volunteerList = list.filter((v) => v.team === 2)
return volunteerList.map((volunteer) => {
const nickname = getUniqueNickname(volunteerList, volunteer)
return {
id: volunteer.id,
nickname,
ok: volunteer.ok,
bof: volunteer.bof,
niet: volunteer.niet,
dayWishes: volunteer.dayWishes,
} as VolunteerDetailedKnowledge
})
})
function getUniqueNickname(list: Volunteer[], volunteer: Volunteer): string {
const lastnameList = list
.filter((v) => v.firstname === volunteer.firstname)
.map((v) => v.lastname)
let lastnamePrefix = ""
while (lastnameList.length > 1) {
lastnamePrefix += volunteer.lastname.charAt(lastnamePrefix.length)
// eslint-disable-next-line no-loop-func
remove(lastnameList, (lastname) => !lastname.startsWith(lastnamePrefix))
}
const nickname = `${volunteer.firstname}${lastnamePrefix ? ` ${lastnamePrefix}.` : ""}`
return nickname
}

View File

@ -38,6 +38,7 @@ import {
volunteerListGet,
volunteerKnowledgeSet,
volunteerAddNew,
volunteerDetailedKnowledgeList,
} from "./gsheets/volunteers"
import { wishListGet, wishAdd } from "./gsheets/wishes"
import config from "../config"
@ -92,6 +93,7 @@ app.get(
* APIs
*/
// Google Sheets API
app.get("/GameDetailsUpdate", gameDetailsUpdate)
app.get("/BoxDetailedListGet", detailedBoxListGet)
app.get("/GameListGet", gameListGet)
app.get("/MiscMeetingDateListGet", miscMeetingDateListGet)
@ -110,6 +112,11 @@ app.get("/TeamListGet", teamListGet)
app.get("/VolunteerDiscordId", secure as RequestHandler, volunteerDiscordId)
app.post("/VolunteerAsksSet", secure as RequestHandler, volunteerAsksSet)
app.post("/VolunteerKnowledgeSet", secure as RequestHandler, volunteerKnowledgeSet)
app.post(
"/VolunteerDetailedKnowledgeListGet",
secure as RequestHandler,
volunteerDetailedKnowledgeList
)
app.post(
"/VolunteerParticipationDetailsSet",
secure as RequestHandler,
@ -125,7 +132,6 @@ app.post("/VolunteerTeamAssignSet", secure as RequestHandler, volunteerTeamAssig
// Admin only
app.post("/VolunteerAddNew", secure as RequestHandler, volunteerAddNew)
app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
app.get("/GameDetailsUpdate", secure as RequestHandler, gameDetailsUpdate)
// Push notification subscription
app.post("/notifications/subscribe", notificationsSubscribe)

View File

@ -43,6 +43,16 @@ export class DetailedBox {
poufpaf = new Game().poufpaf
bggId = new Game().bggId
playersMin = new Game().playersMin
playersMax = new Game().playersMax
duration = new Game().duration
type = new Game().type
container = ""
}
export type DetailedBoxWithoutId = Omit<DetailedBox, "id">

View File

@ -238,3 +238,13 @@ export interface VolunteerKnowledge {
bof: Volunteer["bof"]
niet: Volunteer["niet"]
}
export type VolunteerDetailedKnowledgeWithoutId = Omit<VolunteerDetailedKnowledge, "id">
export interface VolunteerDetailedKnowledge {
id: Volunteer["id"]
nickname: string
ok: Volunteer["ok"]
bof: Volunteer["bof"]
niet: Volunteer["niet"]
dayWishes: Volunteer["dayWishes"]
}

View File

@ -60,3 +60,7 @@ export const volunteerTeamAssignSet =
export const volunteerKnowledgeSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerKnowledge>]>("KnowledgeSet")
export const volunteerDetailedKnowledgeList = serviceAccessors.securedCustomPost<[number]>(
"DetailedKnowledgeListGet"
)

View File

@ -2,11 +2,11 @@ import { PayloadAction, createSlice, createEntityAdapter, createSelector } from
import { sortedUniqBy, sortBy } from "lodash"
import { StateRequest, toastError, elementListFetch } from "./utils"
import { Box } from "../services/boxes"
import { DetailedBox } from "../services/boxes"
import { AppThunk, AppState, EntitiesRequest } from "."
import { detailedBoxListGet } from "../services/boxesAccessors"
const boxAdapter = createEntityAdapter<Box>()
const boxAdapter = createEntityAdapter<DetailedBox>()
export const initialState = boxAdapter.getInitialState({
readyStatus: "idle",
@ -19,7 +19,7 @@ const boxList = createSlice({
getRequesting: (state) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<Box[]>) => {
getSuccess: (state, { payload }: PayloadAction<DetailedBox[]>) => {
state.readyStatus = "success"
boxAdapter.setAll(state, payload)
},
@ -49,7 +49,7 @@ export const fetchBoxListIfNeed = (): AppThunk => (dispatch, getState) => {
return null
}
export const selectBoxListState = (state: AppState): EntitiesRequest<Box> => state.boxList
export const selectBoxListState = (state: AppState): EntitiesRequest<DetailedBox> => state.boxList
export const selectBoxList = createSelector(
selectBoxListState,
@ -60,11 +60,9 @@ export const selectBoxList = createSelector(
)
export const selectSortedUniqueDetailedBoxes = createSelector(selectBoxList, (boxes) =>
sortedUniqBy(
sortBy(
boxes.filter((box) => box && !box.unplayable),
"title"
),
"title"
sortedUniqBy(sortBy(boxes, "title"), "title")
)
export const selectContainerSortedDetailedBoxes = createSelector(selectBoxList, (boxes) =>
sortBy(boxes, "container")
)

View File

@ -21,6 +21,7 @@ import volunteerMealsSet from "./volunteerMealsSet"
import volunteerList from "./volunteerList"
import volunteerLogin from "./volunteerLogin"
import volunteerKnowledgeSet from "./volunteerKnowledgeSet"
import volunteerDetailedKnowledgeList from "./volunteerDetailedKnowledgeList"
import volunteerParticipationDetailsSet from "./volunteerParticipationDetailsSet"
import volunteerPersonalInfoSet from "./volunteerPersonalInfoSet"
import volunteerSet from "./volunteerSet"
@ -52,6 +53,7 @@ export default (history: History) => ({
volunteerList,
volunteerLogin,
volunteerKnowledgeSet,
volunteerDetailedKnowledgeList,
volunteerParticipationDetailsSet,
volunteerPersonalInfoSet,
volunteerSet,

View File

@ -0,0 +1,72 @@
import { PayloadAction, createSlice, createSelector, createEntityAdapter } from "@reduxjs/toolkit"
import { StateRequest, toastError, elementListFetch } from "./utils"
import { VolunteerDetailedKnowledge } from "../services/volunteers"
import { AppThunk, AppState, EntitiesRequest } from "."
import { volunteerDetailedKnowledgeList } from "../services/volunteersAccessors"
const knowledgeAdapter = createEntityAdapter<VolunteerDetailedKnowledge>()
export const initialState = knowledgeAdapter.getInitialState({
readyStatus: "idle",
} as StateRequest)
const volunteerDetailedKnowledgeListSlice = createSlice({
name: "volunteerDetailedKnowledgeList",
initialState,
reducers: {
getRequesting: (state) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<VolunteerDetailedKnowledge[]>) => {
state.readyStatus = "success"
knowledgeAdapter.setAll(state, payload)
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default volunteerDetailedKnowledgeListSlice.reducer
export const { getRequesting, getSuccess, getFailure } = volunteerDetailedKnowledgeListSlice.actions
export const fetchVolunteerDetailedKnowledgeList = elementListFetch(
volunteerDetailedKnowledgeList,
getRequesting,
getSuccess,
getFailure,
(error: Error) =>
toastError(
`Erreur lors du chargement de la liste de connaissances détaillée: ${error.message}`
)
)
const shouldFetchVolunteerDetailedKnowledgeList = (state: AppState) =>
state.volunteerDetailedKnowledgeList?.readyStatus !== "success"
export const fetchVolunteerDetailedKnowledgeListIfNeed =
(id = 0): AppThunk =>
(dispatch, getState) => {
let jwt = ""
if (!id) {
;({ jwt, id } = getState().auth)
}
if (shouldFetchVolunteerDetailedKnowledgeList(getState()))
return dispatch(fetchVolunteerDetailedKnowledgeList(jwt, id))
return null
}
export const selectVolunteerDetailedKnowledgeListState = (
state: AppState
): EntitiesRequest<VolunteerDetailedKnowledge> => state.volunteerDetailedKnowledgeList
export const selectVolunteerDetailedKnowledgeList = createSelector(
selectVolunteerDetailedKnowledgeListState,
({ ids, entities, readyStatus }) => {
if (readyStatus !== "success") return []
return ids.map((id) => entities[id]) as VolunteerDetailedKnowledge[]
}
)