diff --git a/public/css/style.css b/public/css/style.css
index 9c76e0b..9784958 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1461,6 +1461,27 @@ a.back-link:hover { color: var(--accent); }
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 {
position: absolute;
top: 0.3rem;
diff --git a/public/js/calendar.js b/public/js/calendar.js
index a90cb40..e69c3f4 100644
--- a/public/js/calendar.js
+++ b/public/js/calendar.js
@@ -394,6 +394,31 @@ function showError(message) {
// ---------- Répétitions proposées ----------
+function rehearsalVoteActionsHtml(r) {
+ const myVote = me ? r.votes.find((v) => v.userId === me.id) : null;
+ if (!myVote) {
+ return `
+
+
+ `;
+ }
+ const label = myVote.vote === 'accept' ? '✔ Vous avez accepté' : '✘ Vous avez refusé';
+ return `
+ ${label}
+
+ `;
+}
+
+function rehearsalAcceptedByHtml(r) {
+ const accepted = r.votes.filter((v) => v.vote === 'accept');
+ if (!accepted.length) return '';
+ return `
+
+ ${accepted.map((v) => `${avatarHtml({ name: v.name, avatarUrl: v.avatarUrl }, 'avatar-sm')}${escapeHtml(v.name)}`).join('')}
+
+ `;
+}
+
function rehearsalRowTemplate(r) {
const title = 'Répétition Octane';
const linkArgs = { uid: `rehearsal-${r.id}`, title, startISO: r.startsAt, endISO: r.endsAt, location: r.location };
@@ -403,8 +428,10 @@ function rehearsalRowTemplate(r) {
+ ${rehearsalVoteActionsHtml(r)}
+ Google
+ Outlook
+ Apple / autre
@@ -426,6 +453,15 @@ function renderRehearsals() {
container.querySelectorAll('.remove-rehearsal-btn').forEach((btn) => {
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() {
@@ -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) ----------
let myFeeds = [];
diff --git a/src/db/migrations/015_rehearsal_votes.sql b/src/db/migrations/015_rehearsal_votes.sql
new file mode 100644
index 0000000..dfa5020
--- /dev/null
+++ b/src/db/migrations/015_rehearsal_votes.sql
@@ -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)
+);
diff --git a/src/repositories/rehearsalsRepo.js b/src/repositories/rehearsalsRepo.js
index abf52b0..6a7c7b2 100644
--- a/src/repositories/rehearsalsRepo.js
+++ b/src/repositories/rehearsalsRepo.js
@@ -2,10 +2,19 @@ const pool = require('../db/pool');
async function findUpcoming() {
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
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()
+ GROUP BY r.id, u.name
ORDER BY r.starts_at
`);
return rows;
@@ -30,4 +39,23 @@ async function remove(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 };
diff --git a/src/routes/rehearsals.js b/src/routes/rehearsals.js
index 212260e..ea510b0 100644
--- a/src/routes/rehearsals.js
+++ b/src/routes/rehearsals.js
@@ -16,6 +16,7 @@ router.get(
location: r.location,
proposedBy: r.proposed_by,
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;