mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: accept/reject rehersal and get response information
This commit is contained in:
parent
247323654e
commit
5730a9fe3c
5 changed files with 142 additions and 2 deletions
|
|
@ -1461,6 +1461,27 @@ a.back-link:hover { color: var(--accent); }
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rehearsal-accepted-by {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem 0.5rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rehearsal-accepted-by .avatar-sm { width: 1.3rem; height: 1.3rem; font-size: 0.65rem; }
|
||||||
|
|
||||||
|
.vote-badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-badge.accept { border-left: 3px solid var(--success); }
|
||||||
|
.vote-badge.reject { border-left: 3px solid var(--danger); }
|
||||||
|
|
||||||
.cell-badges {
|
.cell-badges {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.3rem;
|
top: 0.3rem;
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,31 @@ function showError(message) {
|
||||||
|
|
||||||
// ---------- Répétitions proposées ----------
|
// ---------- Répétitions proposées ----------
|
||||||
|
|
||||||
|
function rehearsalVoteActionsHtml(r) {
|
||||||
|
const myVote = me ? r.votes.find((v) => v.userId === me.id) : null;
|
||||||
|
if (!myVote) {
|
||||||
|
return `
|
||||||
|
<button type="button" class="secondary accept-rehearsal-btn" data-rehearsal-id="${r.id}">✔ Accepter</button>
|
||||||
|
<button type="button" class="secondary reject-rehearsal-btn" data-rehearsal-id="${r.id}">✘ Refuser</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const label = myVote.vote === 'accept' ? '✔ Vous avez accepté' : '✘ Vous avez refusé';
|
||||||
|
return `
|
||||||
|
<span class="vote-badge ${myVote.vote}">${label}</span>
|
||||||
|
<button type="button" class="secondary undo-rehearsal-vote-btn" data-rehearsal-id="${r.id}">Annuler</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rehearsalAcceptedByHtml(r) {
|
||||||
|
const accepted = r.votes.filter((v) => v.vote === 'accept');
|
||||||
|
if (!accepted.length) return '';
|
||||||
|
return `
|
||||||
|
<div class="rehearsal-accepted-by">
|
||||||
|
${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}<span>${escapeHtml(v.name)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function rehearsalRowTemplate(r) {
|
function rehearsalRowTemplate(r) {
|
||||||
const title = 'Répétition Octane';
|
const title = 'Répétition Octane';
|
||||||
const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location };
|
||||||
|
|
@ -403,8 +428,10 @@ function rehearsalRowTemplate(r) {
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}</div>
|
<div class="card-title">${formatDatetime(r.startsAt)} – ${formatTime(r.endsAt)}</div>
|
||||||
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
<div class="card-subtitle">${r.location ? `${escapeHtml(r.location)} · ` : ''}Proposée par ${escapeHtml(r.proposedByName)}</div>
|
||||||
|
${rehearsalAcceptedByHtml(r)}
|
||||||
</div>
|
</div>
|
||||||
<div class="rehearsal-actions">
|
<div class="rehearsal-actions">
|
||||||
|
${rehearsalVoteActionsHtml(r)}
|
||||||
<a class="pill-link" href="${googleCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Google</a>
|
<a class="pill-link" href="${googleCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Google</a>
|
||||||
<a class="pill-link" href="${outlookCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Outlook</a>
|
<a class="pill-link" href="${outlookCalendarLink(linkArgs)}" target="_blank" rel="noopener">+ Outlook</a>
|
||||||
<a class="pill-link" href="${icsDataUrl(linkArgs)}" download="repetition.ics">+ Apple / autre</a>
|
<a class="pill-link" href="${icsDataUrl(linkArgs)}" download="repetition.ics">+ Apple / autre</a>
|
||||||
|
|
@ -426,6 +453,15 @@ function renderRehearsals() {
|
||||||
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
|
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10)));
|
btn.addEventListener('click', () => onRemoveRehearsal(parseInt(btn.dataset.rehearsalId, 10)));
|
||||||
});
|
});
|
||||||
|
container.querySelectorAll('.accept-rehearsal-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'accept'));
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.reject-rehearsal-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => onVoteRehearsal(parseInt(btn.dataset.rehearsalId, 10), 'reject'));
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.undo-rehearsal-vote-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => onUndoRehearsalVote(parseInt(btn.dataset.rehearsalId, 10)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onProposeRehearsal() {
|
async function onProposeRehearsal() {
|
||||||
|
|
@ -450,6 +486,24 @@ async function onRemoveRehearsal(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onVoteRehearsal(id, vote) {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/rehearsals/${id}/vote`, { vote });
|
||||||
|
await loadRehearsals();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUndoRehearsalVote(id) {
|
||||||
|
try {
|
||||||
|
await api.del(`/api/rehearsals/${id}/vote`);
|
||||||
|
await loadRehearsals();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Mes calendriers (self-service ICS feeds) ----------
|
// ---------- Mes calendriers (self-service ICS feeds) ----------
|
||||||
|
|
||||||
let myFeeds = [];
|
let myFeeds = [];
|
||||||
|
|
|
||||||
9
src/db/migrations/015_rehearsal_votes.sql
Normal file
9
src/db/migrations/015_rehearsal_votes.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
CREATE TABLE rehearsal_votes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
rehearsal_id INTEGER NOT NULL REFERENCES rehearsals(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
vote TEXT NOT NULL CHECK (vote IN ('accept', 'reject')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (rehearsal_id, user_id)
|
||||||
|
);
|
||||||
|
|
@ -2,10 +2,19 @@ const pool = require('../db/pool');
|
||||||
|
|
||||||
async function findUpcoming() {
|
async function findUpcoming() {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at
|
SELECT r.id, r.starts_at, r.ends_at, r.location, r.proposed_by, u.name AS proposed_by_name, r.created_at,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
json_build_object('userId', v.user_id, 'name', vu.name, 'avatarUrl', vu.avatar_url, 'vote', v.vote)
|
||||||
|
) FILTER (WHERE v.id IS NOT NULL),
|
||||||
|
'[]'
|
||||||
|
) AS votes
|
||||||
FROM rehearsals r
|
FROM rehearsals r
|
||||||
JOIN users u ON u.id = r.proposed_by
|
JOIN users u ON u.id = r.proposed_by
|
||||||
|
LEFT JOIN rehearsal_votes v ON v.rehearsal_id = r.id
|
||||||
|
LEFT JOIN users vu ON vu.id = v.user_id
|
||||||
WHERE r.ends_at >= now()
|
WHERE r.ends_at >= now()
|
||||||
|
GROUP BY r.id, u.name
|
||||||
ORDER BY r.starts_at
|
ORDER BY r.starts_at
|
||||||
`);
|
`);
|
||||||
return rows;
|
return rows;
|
||||||
|
|
@ -30,4 +39,23 @@ async function remove(id) {
|
||||||
await pool.query('DELETE FROM rehearsals WHERE id = $1', [id]);
|
await pool.query('DELETE FROM rehearsals WHERE id = $1', [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { findUpcoming, findById, create, remove };
|
async function upsertVote(rehearsalId, userId, vote) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO rehearsal_votes (rehearsal_id, user_id, vote)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (rehearsal_id, user_id)
|
||||||
|
DO UPDATE SET vote = $3, updated_at = now()
|
||||||
|
RETURNING id, rehearsal_id, user_id, vote, created_at, updated_at`,
|
||||||
|
[rehearsalId, userId, vote]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeVote(rehearsalId, userId) {
|
||||||
|
await pool.query('DELETE FROM rehearsal_votes WHERE rehearsal_id = $1 AND user_id = $2', [
|
||||||
|
rehearsalId,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findUpcoming, findById, create, remove, upsertVote, removeVote };
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ router.get(
|
||||||
location: r.location,
|
location: r.location,
|
||||||
proposedBy: r.proposed_by,
|
proposedBy: r.proposed_by,
|
||||||
proposedByName: r.proposed_by_name,
|
proposedByName: r.proposed_by_name,
|
||||||
|
votes: r.votes,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -68,4 +69,31 @@ router.delete(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/vote',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { vote } = req.body || {};
|
||||||
|
if (!['accept', 'reject'].includes(vote)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_vote' });
|
||||||
|
}
|
||||||
|
const rehearsal = await rehearsalsRepo.findById(req.params.id);
|
||||||
|
if (!rehearsal) return res.status(404).json({ error: 'not_found' });
|
||||||
|
const saved = await rehearsalsRepo.upsertVote(req.params.id, req.user.id, vote);
|
||||||
|
res.json({
|
||||||
|
id: saved.id,
|
||||||
|
rehearsalId: saved.rehearsal_id,
|
||||||
|
userId: saved.user_id,
|
||||||
|
vote: saved.vote,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/:id/vote',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
await rehearsalsRepo.removeVote(req.params.id, req.user.id);
|
||||||
|
res.status(204).end();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue