feat(taches): priorité basse/normale/haute des tâchesUnverified
Enum en table (0014), radios-pills Normale pré-cochée, tri des blocs actifs par priorité puis date d'activation (À venir reste chronologique), liseré rouge brique + symbole ‼ pour les hautes (blanc en HC).
e722076 parent: bb2bb6b modified
assets/style.css +14 -0 | @@ -49,6 +49,10 @@ | ||
| 49 | 49 | --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); |
| 50 | 50 | --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); |
| 51 | 51 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); |
| 52 | + | |
| 53 | + /* Priorité haute des tâches : rouge brique dans la famille terreuse de la | |
| 54 | + palette (4.98:1 sur la surface — liseré ET symbole passent l'AA). */ | |
| 55 | + --color-prio: #a03b28; | |
| 52 | 56 | } |
| 53 | 57 | |
| 54 | 58 | /* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══ |
| @@ -86,6 +90,9 @@ | ||
| 86 | 90 | --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); |
| 87 | 91 | --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34); |
| 88 | 92 | --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42); |
| 93 | + | |
| 94 | + /* Pas de rouge en HC : liseré épais neutre, le symbole ‼ porte l'info. */ | |
| 95 | + --color-prio: #ffffff; | |
| 89 | 96 | } |
| 90 | 97 | /* Le toast s'inverse : son fond neutral-900 devient noir en HC. */ |
| 91 | 98 | :root[data-theme="hc"] .toast { background: #ffffff; color: #000000; } |
| @@ -698,6 +705,13 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | ||
| 698 | 705 | .todo-card { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-4); } |
| 699 | 706 | .todo-card.other { background: var(--color-neutral-200); } |
| 700 | 707 | .todo-card.forme { box-shadow: inset 0 0 0 1.5px var(--color-accent-2); } |
| 708 | +/* Priorité haute : liseré + ombre teintée (après .forme : il prime si les | |
| 709 | + deux s'appliquent). Le symbole ‼ (aria-label) double la couleur. */ | |
| 710 | +.todo-card.prio-haute { | |
| 711 | + box-shadow: inset 0 0 0 2px var(--color-prio), | |
| 712 | + 0 3px 10px color-mix(in srgb, var(--color-prio) 30%, transparent); | |
| 713 | +} | |
| 714 | +.prio-flag { color: var(--color-prio); font-weight: 700; } | |
| 701 | 715 | .todo-main { display: flex; align-items: center; gap: var(--space-3); } |
| 702 | 716 | .todo-name { font-size: 16px; } |
| 703 | 717 | .todo-meta { font-size: 13px; opacity: .7; } |
| @@ -49,6 +49,10 @@ | |||
| 49 | --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); | 49 | --shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent); |
| 50 | --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); | 50 | --shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent); |
| 51 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); | 51 | --shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent); |
| 52 | + | ||
| 53 | + /* Priorité haute des tâches : rouge brique dans la famille terreuse de la | ||
| 54 | + palette (4.98:1 sur la surface — liseré ET symbole passent l'AA). */ | ||
| 55 | + --color-prio: #a03b28; | ||
| 52 | } | 56 | } |
| 53 | 57 | ||
| 54 | /* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══ | 58 | /* ═══ Contraste élevé (users.theme = 'hc', data-theme sur <html>) ═══ |
| @@ -86,6 +90,9 @@ | |||
| 86 | --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); | 90 | --shadow-sm: 0 0 0 1px rgba(255, 255, 255, .28); |
| 87 | --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34); | 91 | --shadow-md: 0 0 0 1px rgba(255, 255, 255, .34); |
| 88 | --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42); | 92 | --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .42); |
| 93 | + | ||
| 94 | + /* Pas de rouge en HC : liseré épais neutre, le symbole ‼ porte l'info. */ | ||
| 95 | + --color-prio: #ffffff; | ||
| 89 | } | 96 | } |
| 90 | /* Le toast s'inverse : son fond neutral-900 devient noir en HC. */ | 97 | /* Le toast s'inverse : son fond neutral-900 devient noir en HC. */ |
| 91 | :root[data-theme="hc"] .toast { background: #ffffff; color: #000000; } | 98 | :root[data-theme="hc"] .toast { background: #ffffff; color: #000000; } |
| @@ -698,6 +705,13 @@ h4.add_step_title { margin-top: var(--space-3); margin-bottom: var(--space-3); d | |||
| 698 | .todo-card { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-4); } | 705 | .todo-card { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-4); } |
| 699 | .todo-card.other { background: var(--color-neutral-200); } | 706 | .todo-card.other { background: var(--color-neutral-200); } |
| 700 | .todo-card.forme { box-shadow: inset 0 0 0 1.5px var(--color-accent-2); } | 707 | .todo-card.forme { box-shadow: inset 0 0 0 1.5px var(--color-accent-2); } |
| 708 | +/* Priorité haute : liseré + ombre teintée (après .forme : il prime si les | ||
| 709 | + deux s'appliquent). Le symbole ‼ (aria-label) double la couleur. */ | ||
| 710 | +.todo-card.prio-haute { | ||
| 711 | + box-shadow: inset 0 0 0 2px var(--color-prio), | ||
| 712 | + 0 3px 10px color-mix(in srgb, var(--color-prio) 30%, transparent); | ||
| 713 | +} | ||
| 714 | +.prio-flag { color: var(--color-prio); font-weight: 700; } | ||
| 701 | .todo-main { display: flex; align-items: center; gap: var(--space-3); } | 715 | .todo-main { display: flex; align-items: center; gap: var(--space-3); } |
| 702 | .todo-name { font-size: 16px; } | 716 | .todo-name { font-size: 16px; } |
| 703 | .todo-meta { font-size: 13px; opacity: .7; } | 717 | .todo-meta { font-size: 13px; opacity: .7; } |
added
migrations/0014_todo_priority.sql +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +-- Priorité des tâches : Basse / Normale (défaut) / Haute. Attribut durable | |
| 2 | +-- de la ligne (conservé à la reprogrammation), modifiable dans l'édition. | |
| 3 | +-- Domaine fermé → table enum (cf. 0013), pas de CHECK. | |
| 4 | +CREATE TABLE exego_enums.enum_todo_priority ( | |
| 5 | + code TEXT PRIMARY KEY, | |
| 6 | + sort_order SMALLINT NOT NULL, | |
| 7 | + description TEXT NOT NULL | |
| 8 | +); | |
| 9 | +INSERT INTO exego_enums.enum_todo_priority (code, sort_order, description) VALUES | |
| 10 | + ('basse', 1, 'en bas de liste, aucun marqueur'), | |
| 11 | + ('normale', 2, 'priorité par défaut'), | |
| 12 | + ('haute', 3, 'en haut de liste, liseré rouge'); | |
| 13 | + | |
| 14 | +ALTER TABLE todos ADD COLUMN priority TEXT NOT NULL DEFAULT 'normale' | |
| 15 | + REFERENCES exego_enums.enum_todo_priority(code); | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +-- Priorité des tâches : Basse / Normale (défaut) / Haute. Attribut durable | ||
| 2 | +-- de la ligne (conservé à la reprogrammation), modifiable dans l'édition. | ||
| 3 | +-- Domaine fermé → table enum (cf. 0013), pas de CHECK. | ||
| 4 | +CREATE TABLE exego_enums.enum_todo_priority ( | ||
| 5 | + code TEXT PRIMARY KEY, | ||
| 6 | + sort_order SMALLINT NOT NULL, | ||
| 7 | + description TEXT NOT NULL | ||
| 8 | +); | ||
| 9 | +INSERT INTO exego_enums.enum_todo_priority (code, sort_order, description) VALUES | ||
| 10 | + ('basse', 1, 'en bas de liste, aucun marqueur'), | ||
| 11 | + ('normale', 2, 'priorité par défaut'), | ||
| 12 | + ('haute', 3, 'en haut de liste, liseré rouge'); | ||
| 13 | + | ||
| 14 | +ALTER TABLE todos ADD COLUMN priority TEXT NOT NULL DEFAULT 'normale' | ||
| 15 | + REFERENCES exego_enums.enum_todo_priority(code); | ||
modified
src/db/todos.rs +26 -7 | @@ -8,6 +8,20 @@ pub const STATE_IN_PROGRESS: i16 = 1; | ||
| 8 | 8 | pub const STATE_TODO: i16 = 2; |
| 9 | 9 | pub const STATE_DONE: i16 = 3; |
| 10 | 10 | |
| 11 | +/// Priorités — mêmes codes que `exego_enums.enum_todo_priority`. | |
| 12 | +pub const PRIO_LOW: &str = "basse"; | |
| 13 | +pub const PRIO_NORMAL: &str = "normale"; | |
| 14 | +pub const PRIO_HIGH: &str = "haute"; | |
| 15 | + | |
| 16 | +/// Rang de tri (haute d'abord) — un code inconnu vaut normale. | |
| 17 | +pub fn prio_rank(p: &str) -> u8 { | |
| 18 | + match p { | |
| 19 | + PRIO_HIGH => 0, | |
| 20 | + PRIO_LOW => 2, | |
| 21 | + _ => 1, | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 11 | 25 | #[derive(sqlx::FromRow)] |
| 12 | 26 | pub struct TodoRow { |
| 13 | 27 | pub id: Uuid, |
| @@ -15,6 +29,7 @@ pub struct TodoRow { | ||
| 15 | 29 | pub category_id: Uuid, |
| 16 | 30 | pub activation_date: NaiveDate, |
| 17 | 31 | pub state_id: i16, |
| 32 | + pub priority: String, | |
| 18 | 33 | pub created_at: DateTime<Utc>, |
| 19 | 34 | /// Preneur si « en cours » (dernier passage à in_progress). |
| 20 | 35 | pub taker_id: Option<Uuid>, |
| @@ -41,7 +56,7 @@ const GUARD: &str = "t.id = $1 | ||
| 41 | 56 | /// Toutes les todos non faites de l'équipe (les trois blocs se filtrent en Rust). |
| 42 | 57 | pub async fn list_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TodoRow>> { |
| 43 | 58 | sqlx::query_as( |
| 44 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | |
| 59 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, | |
| 45 | 60 | tk.user_id AS taker_id, tk.username AS taker_name, tk.changed_at AS taken_at |
| 46 | 61 | FROM todos t |
| 47 | 62 | JOIN categories c ON c.id = t.category_id |
| @@ -98,13 +113,14 @@ pub async fn create( | ||
| 98 | 113 | category_id: Uuid, |
| 99 | 114 | name: &str, |
| 100 | 115 | activation: NaiveDate, |
| 116 | + priority: &str, | |
| 101 | 117 | assignees: &[Uuid], |
| 102 | 118 | ) -> sqlx::Result<u64> { |
| 103 | 119 | let mut tx = pool.begin().await?; |
| 104 | 120 | let id = Uuid::now_v7(); |
| 105 | 121 | let created = sqlx::query( |
| 106 | - "INSERT INTO todos (id, category_id, name, activation_date, state_id) | |
| 107 | - SELECT $1, $2, $3, $4, 2 | |
| 122 | + "INSERT INTO todos (id, category_id, name, activation_date, state_id, priority) | |
| 123 | + SELECT $1, $2, $3, $4, 2, $6 | |
| 108 | 124 | WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND team_id = $5)", |
| 109 | 125 | ) |
| 110 | 126 | .bind(id) |
| @@ -112,6 +128,7 @@ pub async fn create( | ||
| 112 | 128 | .bind(name) |
| 113 | 129 | .bind(activation) |
| 114 | 130 | .bind(team_id) |
| 131 | + .bind(priority) | |
| 115 | 132 | .execute(&mut *tx) |
| 116 | 133 | .await? |
| 117 | 134 | .rows_affected(); |
| @@ -132,7 +149,7 @@ pub async fn get_editable( | ||
| 132 | 149 | id: Uuid, |
| 133 | 150 | ) -> sqlx::Result<Option<TodoRow>> { |
| 134 | 151 | sqlx::query_as(&format!( |
| 135 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | |
| 152 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, | |
| 136 | 153 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at |
| 137 | 154 | FROM todos t WHERE {GUARD} AND t.state_id <> 3" |
| 138 | 155 | )) |
| @@ -151,7 +168,7 @@ pub async fn assignees_of(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<Uuid>> { | ||
| 151 | 168 | .await |
| 152 | 169 | } |
| 153 | 170 | |
| 154 | -/// Édition (nom, catégorie de la même équipe, date, assignés). | |
| 171 | +/// Édition (nom, catégorie de la même équipe, date, priorité, assignés). | |
| 155 | 172 | pub async fn update( |
| 156 | 173 | pool: &PgPool, |
| 157 | 174 | team_id: Uuid, |
| @@ -160,11 +177,12 @@ pub async fn update( | ||
| 160 | 177 | category_id: Uuid, |
| 161 | 178 | name: &str, |
| 162 | 179 | activation: NaiveDate, |
| 180 | + priority: &str, | |
| 163 | 181 | assignees: &[Uuid], |
| 164 | 182 | ) -> sqlx::Result<u64> { |
| 165 | 183 | let mut tx = pool.begin().await?; |
| 166 | 184 | let n = sqlx::query(&format!( |
| 167 | - "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6 | |
| 185 | + "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6, priority = $7 | |
| 168 | 186 | WHERE {GUARD} AND t.state_id <> 3 |
| 169 | 187 | AND EXISTS (SELECT 1 FROM categories WHERE id = $4 AND team_id = $2)" |
| 170 | 188 | )) |
| @@ -174,6 +192,7 @@ pub async fn update( | ||
| 174 | 192 | .bind(category_id) |
| 175 | 193 | .bind(name) |
| 176 | 194 | .bind(activation) |
| 195 | + .bind(priority) | |
| 177 | 196 | .execute(&mut *tx) |
| 178 | 197 | .await? |
| 179 | 198 | .rows_affected(); |
| @@ -250,7 +269,7 @@ pub async fn get_finishable( | ||
| 250 | 269 | id: Uuid, |
| 251 | 270 | ) -> sqlx::Result<Option<TodoRow>> { |
| 252 | 271 | sqlx::query_as(&format!( |
| 253 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | |
| 272 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, | |
| 254 | 273 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at |
| 255 | 274 | FROM todos t WHERE {GUARD} AND t.state_id IN (1, 2)" |
| 256 | 275 | )) |
| @@ -8,6 +8,20 @@ pub const STATE_IN_PROGRESS: i16 = 1; | |||
| 8 | pub const STATE_TODO: i16 = 2; | 8 | pub const STATE_TODO: i16 = 2; |
| 9 | pub const STATE_DONE: i16 = 3; | 9 | pub const STATE_DONE: i16 = 3; |
| 10 | 10 | ||
| 11 | +/// Priorités — mêmes codes que `exego_enums.enum_todo_priority`. | ||
| 12 | +pub const PRIO_LOW: &str = "basse"; | ||
| 13 | +pub const PRIO_NORMAL: &str = "normale"; | ||
| 14 | +pub const PRIO_HIGH: &str = "haute"; | ||
| 15 | + | ||
| 16 | +/// Rang de tri (haute d'abord) — un code inconnu vaut normale. | ||
| 17 | +pub fn prio_rank(p: &str) -> u8 { | ||
| 18 | + match p { | ||
| 19 | + PRIO_HIGH => 0, | ||
| 20 | + PRIO_LOW => 2, | ||
| 21 | + _ => 1, | ||
| 22 | + } | ||
| 23 | +} | ||
| 24 | + | ||
| 11 | #[derive(sqlx::FromRow)] | 25 | #[derive(sqlx::FromRow)] |
| 12 | pub struct TodoRow { | 26 | pub struct TodoRow { |
| 13 | pub id: Uuid, | 27 | pub id: Uuid, |
| @@ -15,6 +29,7 @@ pub struct TodoRow { | |||
| 15 | pub category_id: Uuid, | 29 | pub category_id: Uuid, |
| 16 | pub activation_date: NaiveDate, | 30 | pub activation_date: NaiveDate, |
| 17 | pub state_id: i16, | 31 | pub state_id: i16, |
| 32 | + pub priority: String, | ||
| 18 | pub created_at: DateTime<Utc>, | 33 | pub created_at: DateTime<Utc>, |
| 19 | /// Preneur si « en cours » (dernier passage à in_progress). | 34 | /// Preneur si « en cours » (dernier passage à in_progress). |
| 20 | pub taker_id: Option<Uuid>, | 35 | pub taker_id: Option<Uuid>, |
| @@ -41,7 +56,7 @@ const GUARD: &str = "t.id = $1 | |||
| 41 | /// Toutes les todos non faites de l'équipe (les trois blocs se filtrent en Rust). | 56 | /// Toutes les todos non faites de l'équipe (les trois blocs se filtrent en Rust). |
| 42 | pub async fn list_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TodoRow>> { | 57 | pub async fn list_open(pool: &PgPool, team_id: Uuid) -> sqlx::Result<Vec<TodoRow>> { |
| 43 | sqlx::query_as( | 58 | sqlx::query_as( |
| 44 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | 59 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, |
| 45 | tk.user_id AS taker_id, tk.username AS taker_name, tk.changed_at AS taken_at | 60 | tk.user_id AS taker_id, tk.username AS taker_name, tk.changed_at AS taken_at |
| 46 | FROM todos t | 61 | FROM todos t |
| 47 | JOIN categories c ON c.id = t.category_id | 62 | JOIN categories c ON c.id = t.category_id |
| @@ -98,13 +113,14 @@ pub async fn create( | |||
| 98 | category_id: Uuid, | 113 | category_id: Uuid, |
| 99 | name: &str, | 114 | name: &str, |
| 100 | activation: NaiveDate, | 115 | activation: NaiveDate, |
| 116 | + priority: &str, | ||
| 101 | assignees: &[Uuid], | 117 | assignees: &[Uuid], |
| 102 | ) -> sqlx::Result<u64> { | 118 | ) -> sqlx::Result<u64> { |
| 103 | let mut tx = pool.begin().await?; | 119 | let mut tx = pool.begin().await?; |
| 104 | let id = Uuid::now_v7(); | 120 | let id = Uuid::now_v7(); |
| 105 | let created = sqlx::query( | 121 | let created = sqlx::query( |
| 106 | - "INSERT INTO todos (id, category_id, name, activation_date, state_id) | 122 | + "INSERT INTO todos (id, category_id, name, activation_date, state_id, priority) |
| 107 | - SELECT $1, $2, $3, $4, 2 | 123 | + SELECT $1, $2, $3, $4, 2, $6 |
| 108 | WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND team_id = $5)", | 124 | WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND team_id = $5)", |
| 109 | ) | 125 | ) |
| 110 | .bind(id) | 126 | .bind(id) |
| @@ -112,6 +128,7 @@ pub async fn create( | |||
| 112 | .bind(name) | 128 | .bind(name) |
| 113 | .bind(activation) | 129 | .bind(activation) |
| 114 | .bind(team_id) | 130 | .bind(team_id) |
| 131 | + .bind(priority) | ||
| 115 | .execute(&mut *tx) | 132 | .execute(&mut *tx) |
| 116 | .await? | 133 | .await? |
| 117 | .rows_affected(); | 134 | .rows_affected(); |
| @@ -132,7 +149,7 @@ pub async fn get_editable( | |||
| 132 | id: Uuid, | 149 | id: Uuid, |
| 133 | ) -> sqlx::Result<Option<TodoRow>> { | 150 | ) -> sqlx::Result<Option<TodoRow>> { |
| 134 | sqlx::query_as(&format!( | 151 | sqlx::query_as(&format!( |
| 135 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | 152 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, |
| 136 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at | 153 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at |
| 137 | FROM todos t WHERE {GUARD} AND t.state_id <> 3" | 154 | FROM todos t WHERE {GUARD} AND t.state_id <> 3" |
| 138 | )) | 155 | )) |
| @@ -151,7 +168,7 @@ pub async fn assignees_of(pool: &PgPool, id: Uuid) -> sqlx::Result<Vec<Uuid>> { | |||
| 151 | .await | 168 | .await |
| 152 | } | 169 | } |
| 153 | 170 | ||
| 154 | -/// Édition (nom, catégorie de la même équipe, date, assignés). | 171 | +/// Édition (nom, catégorie de la même équipe, date, priorité, assignés). |
| 155 | pub async fn update( | 172 | pub async fn update( |
| 156 | pool: &PgPool, | 173 | pool: &PgPool, |
| 157 | team_id: Uuid, | 174 | team_id: Uuid, |
| @@ -160,11 +177,12 @@ pub async fn update( | |||
| 160 | category_id: Uuid, | 177 | category_id: Uuid, |
| 161 | name: &str, | 178 | name: &str, |
| 162 | activation: NaiveDate, | 179 | activation: NaiveDate, |
| 180 | + priority: &str, | ||
| 163 | assignees: &[Uuid], | 181 | assignees: &[Uuid], |
| 164 | ) -> sqlx::Result<u64> { | 182 | ) -> sqlx::Result<u64> { |
| 165 | let mut tx = pool.begin().await?; | 183 | let mut tx = pool.begin().await?; |
| 166 | let n = sqlx::query(&format!( | 184 | let n = sqlx::query(&format!( |
| 167 | - "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6 | 185 | + "UPDATE todos t SET category_id = $4, name = $5, activation_date = $6, priority = $7 |
| 168 | WHERE {GUARD} AND t.state_id <> 3 | 186 | WHERE {GUARD} AND t.state_id <> 3 |
| 169 | AND EXISTS (SELECT 1 FROM categories WHERE id = $4 AND team_id = $2)" | 187 | AND EXISTS (SELECT 1 FROM categories WHERE id = $4 AND team_id = $2)" |
| 170 | )) | 188 | )) |
| @@ -174,6 +192,7 @@ pub async fn update( | |||
| 174 | .bind(category_id) | 192 | .bind(category_id) |
| 175 | .bind(name) | 193 | .bind(name) |
| 176 | .bind(activation) | 194 | .bind(activation) |
| 195 | + .bind(priority) | ||
| 177 | .execute(&mut *tx) | 196 | .execute(&mut *tx) |
| 178 | .await? | 197 | .await? |
| 179 | .rows_affected(); | 198 | .rows_affected(); |
| @@ -250,7 +269,7 @@ pub async fn get_finishable( | |||
| 250 | id: Uuid, | 269 | id: Uuid, |
| 251 | ) -> sqlx::Result<Option<TodoRow>> { | 270 | ) -> sqlx::Result<Option<TodoRow>> { |
| 252 | sqlx::query_as(&format!( | 271 | sqlx::query_as(&format!( |
| 253 | - "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.created_at, | 272 | + "SELECT t.id, t.name, t.category_id, t.activation_date, t.state_id, t.priority, t.created_at, |
| 254 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at | 273 | NULL::uuid AS taker_id, NULL::text AS taker_name, NULL::timestamptz AS taken_at |
| 255 | FROM todos t WHERE {GUARD} AND t.state_id IN (1, 2)" | 274 | FROM todos t WHERE {GUARD} AND t.state_id IN (1, 2)" |
| 256 | )) | 275 | )) |
modified
src/i18n.rs +43 -0 | @@ -252,6 +252,11 @@ pub struct Tr { | ||
| 252 | 252 | pub change_password: &'static str, |
| 253 | 253 | pub sign_out: &'static str, |
| 254 | 254 | pub signout_note: &'static str, |
| 255 | + /// Carte Compte : accroche + lien vers la création d'une issue GitLab. | |
| 256 | + pub feedback_title: &'static str, | |
| 257 | + pub feedback_link: &'static str, | |
| 258 | + /// Description préremplie de l'issue — p2 : version puis commit. | |
| 259 | + pub issue_body_pat: &'static str, | |
| 255 | 260 | pub toast_lang_updated: &'static str, |
| 256 | 261 | pub toast_password_updated: &'static str, |
| 257 | 262 | pub toast_invite_sent: &'static str, |
| @@ -342,6 +347,12 @@ pub struct Tr { | ||
| 342 | 347 | pub toast_todo_released: &'static str, |
| 343 | 348 | pub toast_todo_done: &'static str, |
| 344 | 349 | pub toast_todo_done_reprog: &'static str, |
| 350 | + /// Priorité (Basse / Normale / Haute) — radios du formulaire + marqueur. | |
| 351 | + pub prio_label: &'static str, | |
| 352 | + pub prio_low: &'static str, | |
| 353 | + pub prio_normal: &'static str, | |
| 354 | + pub prio_high: &'static str, | |
| 355 | + pub prio_high_aria: &'static str, | |
| 345 | 356 | // ─── erreurs ─── |
| 346 | 357 | pub err_404: &'static str, |
| 347 | 358 | pub err_csrf: &'static str, |
| @@ -551,6 +562,9 @@ pub static FR: Tr = Tr { | ||
| 551 | 562 | change_password: "Changer le mot de passe", |
| 552 | 563 | sign_out: "Se déconnecter", |
| 553 | 564 | signout_note: "Le biscuit de session sera révoqué.", |
| 565 | + feedback_title: "Un problème ? Une idée ?", | |
| 566 | + feedback_link: "Viens m'en parler ici", | |
| 567 | + issue_body_pat: "<!-- Décris ton problème ou ton idée -->\n\n---\nVersion : {} — commit {}", | |
| 554 | 568 | toast_lang_updated: "Langue mise à jour", |
| 555 | 569 | toast_password_updated: "Mot de passe mis à jour", |
| 556 | 570 | toast_invite_sent: "Invitation envoyée", |
| @@ -636,6 +650,11 @@ pub static FR: Tr = Tr { | ||
| 636 | 650 | toast_todo_released: "Tâche relâchée", |
| 637 | 651 | toast_todo_done: "Tâche faite ✓", |
| 638 | 652 | toast_todo_done_reprog: "Faite et reprogrammée ✓", |
| 653 | + prio_label: "Priorité", | |
| 654 | + prio_low: "Basse", | |
| 655 | + prio_normal: "Normale", | |
| 656 | + prio_high: "Haute", | |
| 657 | + prio_high_aria: "Priorité haute", | |
| 639 | 658 | err_404: "Page introuvable.", |
| 640 | 659 | err_csrf: "Formulaire expiré, veuillez réessayer.", |
| 641 | 660 | err_500: "Erreur interne.", |
| @@ -816,6 +835,9 @@ pub static EN: Tr = Tr { | ||
| 816 | 835 | change_password: "Change password", |
| 817 | 836 | sign_out: "Sign out", |
| 818 | 837 | signout_note: "The session biscuit will be revoked.", |
| 838 | + feedback_title: "A problem? An idea?", | |
| 839 | + feedback_link: "Come tell me about it here", | |
| 840 | + issue_body_pat: "<!-- Describe your problem or your idea -->\n\n---\nVersion: {} — commit {}", | |
| 819 | 841 | toast_lang_updated: "Language updated", |
| 820 | 842 | toast_password_updated: "Password updated", |
| 821 | 843 | toast_invite_sent: "Invitation sent", |
| @@ -901,6 +923,11 @@ pub static EN: Tr = Tr { | ||
| 901 | 923 | toast_todo_released: "Task released", |
| 902 | 924 | toast_todo_done: "Task done ✓", |
| 903 | 925 | toast_todo_done_reprog: "Done and rescheduled ✓", |
| 926 | + prio_label: "Priority", | |
| 927 | + prio_low: "Low", | |
| 928 | + prio_normal: "Normal", | |
| 929 | + prio_high: "High", | |
| 930 | + prio_high_aria: "High priority", | |
| 904 | 931 | err_404: "Page not found.", |
| 905 | 932 | err_csrf: "The form expired, please try again.", |
| 906 | 933 | err_500: "Internal error.", |
| @@ -1081,6 +1108,9 @@ pub static DE: Tr = Tr { | ||
| 1081 | 1108 | change_password: "Passwort ändern", |
| 1082 | 1109 | sign_out: "Abmelden", |
| 1083 | 1110 | signout_note: "Das Sitzungs-Biscuit wird widerrufen.", |
| 1111 | + feedback_title: "Ein Problem? Eine Idee?", | |
| 1112 | + feedback_link: "Erzähl mir hier davon", | |
| 1113 | + issue_body_pat: "<!-- Beschreib dein Problem oder deine Idee -->\n\n---\nVersion: {} — Commit {}", | |
| 1084 | 1114 | toast_lang_updated: "Sprache aktualisiert", |
| 1085 | 1115 | toast_password_updated: "Passwort aktualisiert", |
| 1086 | 1116 | toast_invite_sent: "Einladung gesendet", |
| @@ -1166,6 +1196,11 @@ pub static DE: Tr = Tr { | ||
| 1166 | 1196 | toast_todo_released: "Aufgabe freigegeben", |
| 1167 | 1197 | toast_todo_done: "Aufgabe erledigt ✓", |
| 1168 | 1198 | toast_todo_done_reprog: "Erledigt und neu geplant ✓", |
| 1199 | + prio_label: "Priorität", | |
| 1200 | + prio_low: "Niedrig", | |
| 1201 | + prio_normal: "Normal", | |
| 1202 | + prio_high: "Hoch", | |
| 1203 | + prio_high_aria: "Hohe Priorität", | |
| 1169 | 1204 | err_404: "Seite nicht gefunden.", |
| 1170 | 1205 | err_csrf: "Das Formular ist abgelaufen, bitte erneut versuchen.", |
| 1171 | 1206 | err_500: "Interner Fehler.", |
| @@ -1346,6 +1381,9 @@ pub static PT_BR: Tr = Tr { | ||
| 1346 | 1381 | change_password: "Alterar a senha", |
| 1347 | 1382 | sign_out: "Sair da conta", |
| 1348 | 1383 | signout_note: "O biscuit da sessão será revogado.", |
| 1384 | + feedback_title: "Um problema? Uma ideia?", | |
| 1385 | + feedback_link: "Vem me contar aqui", | |
| 1386 | + issue_body_pat: "<!-- Descreva seu problema ou sua ideia -->\n\n---\nVersão: {} — commit {}", | |
| 1349 | 1387 | toast_lang_updated: "Idioma atualizado", |
| 1350 | 1388 | toast_password_updated: "Senha atualizada", |
| 1351 | 1389 | toast_invite_sent: "Convite enviado", |
| @@ -1431,6 +1469,11 @@ pub static PT_BR: Tr = Tr { | ||
| 1431 | 1469 | toast_todo_released: "Tarefa solta", |
| 1432 | 1470 | toast_todo_done: "Tarefa feita ✓", |
| 1433 | 1471 | toast_todo_done_reprog: "Feita e reprogramada ✓", |
| 1472 | + prio_label: "Prioridade", | |
| 1473 | + prio_low: "Baixa", | |
| 1474 | + prio_normal: "Normal", | |
| 1475 | + prio_high: "Alta", | |
| 1476 | + prio_high_aria: "Prioridade alta", | |
| 1434 | 1477 | err_404: "Página não encontrada.", |
| 1435 | 1478 | err_csrf: "O formulário expirou, tente de novo.", |
| 1436 | 1479 | err_500: "Erro interno.", |
| @@ -252,6 +252,11 @@ pub struct Tr { | |||
| 252 | pub change_password: &'static str, | 252 | pub change_password: &'static str, |
| 253 | pub sign_out: &'static str, | 253 | pub sign_out: &'static str, |
| 254 | pub signout_note: &'static str, | 254 | pub signout_note: &'static str, |
| 255 | + /// Carte Compte : accroche + lien vers la création d'une issue GitLab. | ||
| 256 | + pub feedback_title: &'static str, | ||
| 257 | + pub feedback_link: &'static str, | ||
| 258 | + /// Description préremplie de l'issue — p2 : version puis commit. | ||
| 259 | + pub issue_body_pat: &'static str, | ||
| 255 | pub toast_lang_updated: &'static str, | 260 | pub toast_lang_updated: &'static str, |
| 256 | pub toast_password_updated: &'static str, | 261 | pub toast_password_updated: &'static str, |
| 257 | pub toast_invite_sent: &'static str, | 262 | pub toast_invite_sent: &'static str, |
| @@ -342,6 +347,12 @@ pub struct Tr { | |||
| 342 | pub toast_todo_released: &'static str, | 347 | pub toast_todo_released: &'static str, |
| 343 | pub toast_todo_done: &'static str, | 348 | pub toast_todo_done: &'static str, |
| 344 | pub toast_todo_done_reprog: &'static str, | 349 | pub toast_todo_done_reprog: &'static str, |
| 350 | + /// Priorité (Basse / Normale / Haute) — radios du formulaire + marqueur. | ||
| 351 | + pub prio_label: &'static str, | ||
| 352 | + pub prio_low: &'static str, | ||
| 353 | + pub prio_normal: &'static str, | ||
| 354 | + pub prio_high: &'static str, | ||
| 355 | + pub prio_high_aria: &'static str, | ||
| 345 | // ─── erreurs ─── | 356 | // ─── erreurs ─── |
| 346 | pub err_404: &'static str, | 357 | pub err_404: &'static str, |
| 347 | pub err_csrf: &'static str, | 358 | pub err_csrf: &'static str, |
| @@ -551,6 +562,9 @@ pub static FR: Tr = Tr { | |||
| 551 | change_password: "Changer le mot de passe", | 562 | change_password: "Changer le mot de passe", |
| 552 | sign_out: "Se déconnecter", | 563 | sign_out: "Se déconnecter", |
| 553 | signout_note: "Le biscuit de session sera révoqué.", | 564 | signout_note: "Le biscuit de session sera révoqué.", |
| 565 | + feedback_title: "Un problème ? Une idée ?", | ||
| 566 | + feedback_link: "Viens m'en parler ici", | ||
| 567 | + issue_body_pat: "<!-- Décris ton problème ou ton idée -->\n\n---\nVersion : {} — commit {}", | ||
| 554 | toast_lang_updated: "Langue mise à jour", | 568 | toast_lang_updated: "Langue mise à jour", |
| 555 | toast_password_updated: "Mot de passe mis à jour", | 569 | toast_password_updated: "Mot de passe mis à jour", |
| 556 | toast_invite_sent: "Invitation envoyée", | 570 | toast_invite_sent: "Invitation envoyée", |
| @@ -636,6 +650,11 @@ pub static FR: Tr = Tr { | |||
| 636 | toast_todo_released: "Tâche relâchée", | 650 | toast_todo_released: "Tâche relâchée", |
| 637 | toast_todo_done: "Tâche faite ✓", | 651 | toast_todo_done: "Tâche faite ✓", |
| 638 | toast_todo_done_reprog: "Faite et reprogrammée ✓", | 652 | toast_todo_done_reprog: "Faite et reprogrammée ✓", |
| 653 | + prio_label: "Priorité", | ||
| 654 | + prio_low: "Basse", | ||
| 655 | + prio_normal: "Normale", | ||
| 656 | + prio_high: "Haute", | ||
| 657 | + prio_high_aria: "Priorité haute", | ||
| 639 | err_404: "Page introuvable.", | 658 | err_404: "Page introuvable.", |
| 640 | err_csrf: "Formulaire expiré, veuillez réessayer.", | 659 | err_csrf: "Formulaire expiré, veuillez réessayer.", |
| 641 | err_500: "Erreur interne.", | 660 | err_500: "Erreur interne.", |
| @@ -816,6 +835,9 @@ pub static EN: Tr = Tr { | |||
| 816 | change_password: "Change password", | 835 | change_password: "Change password", |
| 817 | sign_out: "Sign out", | 836 | sign_out: "Sign out", |
| 818 | signout_note: "The session biscuit will be revoked.", | 837 | signout_note: "The session biscuit will be revoked.", |
| 838 | + feedback_title: "A problem? An idea?", | ||
| 839 | + feedback_link: "Come tell me about it here", | ||
| 840 | + issue_body_pat: "<!-- Describe your problem or your idea -->\n\n---\nVersion: {} — commit {}", | ||
| 819 | toast_lang_updated: "Language updated", | 841 | toast_lang_updated: "Language updated", |
| 820 | toast_password_updated: "Password updated", | 842 | toast_password_updated: "Password updated", |
| 821 | toast_invite_sent: "Invitation sent", | 843 | toast_invite_sent: "Invitation sent", |
| @@ -901,6 +923,11 @@ pub static EN: Tr = Tr { | |||
| 901 | toast_todo_released: "Task released", | 923 | toast_todo_released: "Task released", |
| 902 | toast_todo_done: "Task done ✓", | 924 | toast_todo_done: "Task done ✓", |
| 903 | toast_todo_done_reprog: "Done and rescheduled ✓", | 925 | toast_todo_done_reprog: "Done and rescheduled ✓", |
| 926 | + prio_label: "Priority", | ||
| 927 | + prio_low: "Low", | ||
| 928 | + prio_normal: "Normal", | ||
| 929 | + prio_high: "High", | ||
| 930 | + prio_high_aria: "High priority", | ||
| 904 | err_404: "Page not found.", | 931 | err_404: "Page not found.", |
| 905 | err_csrf: "The form expired, please try again.", | 932 | err_csrf: "The form expired, please try again.", |
| 906 | err_500: "Internal error.", | 933 | err_500: "Internal error.", |
| @@ -1081,6 +1108,9 @@ pub static DE: Tr = Tr { | |||
| 1081 | change_password: "Passwort ändern", | 1108 | change_password: "Passwort ändern", |
| 1082 | sign_out: "Abmelden", | 1109 | sign_out: "Abmelden", |
| 1083 | signout_note: "Das Sitzungs-Biscuit wird widerrufen.", | 1110 | signout_note: "Das Sitzungs-Biscuit wird widerrufen.", |
| 1111 | + feedback_title: "Ein Problem? Eine Idee?", | ||
| 1112 | + feedback_link: "Erzähl mir hier davon", | ||
| 1113 | + issue_body_pat: "<!-- Beschreib dein Problem oder deine Idee -->\n\n---\nVersion: {} — Commit {}", | ||
| 1084 | toast_lang_updated: "Sprache aktualisiert", | 1114 | toast_lang_updated: "Sprache aktualisiert", |
| 1085 | toast_password_updated: "Passwort aktualisiert", | 1115 | toast_password_updated: "Passwort aktualisiert", |
| 1086 | toast_invite_sent: "Einladung gesendet", | 1116 | toast_invite_sent: "Einladung gesendet", |
| @@ -1166,6 +1196,11 @@ pub static DE: Tr = Tr { | |||
| 1166 | toast_todo_released: "Aufgabe freigegeben", | 1196 | toast_todo_released: "Aufgabe freigegeben", |
| 1167 | toast_todo_done: "Aufgabe erledigt ✓", | 1197 | toast_todo_done: "Aufgabe erledigt ✓", |
| 1168 | toast_todo_done_reprog: "Erledigt und neu geplant ✓", | 1198 | toast_todo_done_reprog: "Erledigt und neu geplant ✓", |
| 1199 | + prio_label: "Priorität", | ||
| 1200 | + prio_low: "Niedrig", | ||
| 1201 | + prio_normal: "Normal", | ||
| 1202 | + prio_high: "Hoch", | ||
| 1203 | + prio_high_aria: "Hohe Priorität", | ||
| 1169 | err_404: "Seite nicht gefunden.", | 1204 | err_404: "Seite nicht gefunden.", |
| 1170 | err_csrf: "Das Formular ist abgelaufen, bitte erneut versuchen.", | 1205 | err_csrf: "Das Formular ist abgelaufen, bitte erneut versuchen.", |
| 1171 | err_500: "Interner Fehler.", | 1206 | err_500: "Interner Fehler.", |
| @@ -1346,6 +1381,9 @@ pub static PT_BR: Tr = Tr { | |||
| 1346 | change_password: "Alterar a senha", | 1381 | change_password: "Alterar a senha", |
| 1347 | sign_out: "Sair da conta", | 1382 | sign_out: "Sair da conta", |
| 1348 | signout_note: "O biscuit da sessão será revogado.", | 1383 | signout_note: "O biscuit da sessão será revogado.", |
| 1384 | + feedback_title: "Um problema? Uma ideia?", | ||
| 1385 | + feedback_link: "Vem me contar aqui", | ||
| 1386 | + issue_body_pat: "<!-- Descreva seu problema ou sua ideia -->\n\n---\nVersão: {} — commit {}", | ||
| 1349 | toast_lang_updated: "Idioma atualizado", | 1387 | toast_lang_updated: "Idioma atualizado", |
| 1350 | toast_password_updated: "Senha atualizada", | 1388 | toast_password_updated: "Senha atualizada", |
| 1351 | toast_invite_sent: "Convite enviado", | 1389 | toast_invite_sent: "Convite enviado", |
| @@ -1431,6 +1469,11 @@ pub static PT_BR: Tr = Tr { | |||
| 1431 | toast_todo_released: "Tarefa solta", | 1469 | toast_todo_released: "Tarefa solta", |
| 1432 | toast_todo_done: "Tarefa feita ✓", | 1470 | toast_todo_done: "Tarefa feita ✓", |
| 1433 | toast_todo_done_reprog: "Feita e reprogramada ✓", | 1471 | toast_todo_done_reprog: "Feita e reprogramada ✓", |
| 1472 | + prio_label: "Prioridade", | ||
| 1473 | + prio_low: "Baixa", | ||
| 1474 | + prio_normal: "Normal", | ||
| 1475 | + prio_high: "Alta", | ||
| 1476 | + prio_high_aria: "Prioridade alta", | ||
| 1434 | err_404: "Página não encontrada.", | 1477 | err_404: "Página não encontrada.", |
| 1435 | err_csrf: "O formulário expirou, tente de novo.", | 1478 | err_csrf: "O formulário expirou, tente de novo.", |
| 1436 | err_500: "Erro interno.", | 1479 | err_500: "Erro interno.", |
modified
src/routes/todos.rs +38 -3 | @@ -12,7 +12,7 @@ use uuid::Uuid; | ||
| 12 | 12 | use crate::charts::{color_map, fmt_minutes}; |
| 13 | 13 | use crate::db::categories; |
| 14 | 14 | use crate::db::teams; |
| 15 | -use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow}; | |
| 15 | +use crate::db::todos::{self, PRIO_HIGH, PRIO_LOW, PRIO_NORMAL, STATE_IN_PROGRESS, STATE_TODO, TodoRow, prio_rank}; | |
| 16 | 16 | use crate::error::AppError; |
| 17 | 17 | use crate::i18n::{Tr, p1, p2}; |
| 18 | 18 | use crate::period::{DateVocab, day_label, day_month, paris_today}; |
| @@ -49,6 +49,9 @@ pub struct TodoCard { | ||
| 49 | 49 | pub assignees: Vec<AsgChip>, |
| 50 | 50 | /// Je suis assigné → liseré sur la carte. |
| 51 | 51 | pub for_me: bool, |
| 52 | + /// Priorité haute → liseré rouge + symbole (blocs actifs seulement, | |
| 53 | + /// le bloc « À venir » reste neutre). | |
| 54 | + pub high: bool, | |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | #[derive(Template)] |
| @@ -157,6 +160,7 @@ pub async fn todos_page( | ||
| 157 | 160 | }) |
| 158 | 161 | .collect(), |
| 159 | 162 | for_me, |
| 163 | + high: t.priority == PRIO_HIGH, | |
| 160 | 164 | } |
| 161 | 165 | }; |
| 162 | 166 | |
| @@ -183,6 +187,12 @@ pub async fn todos_page( | ||
| 183 | 187 | // Les miennes en premier, puis les sans-assigné (stable). |
| 184 | 188 | sel.sort_by_key(|t| !assigned_to_me(t)); |
| 185 | 189 | } |
| 190 | + if active { | |
| 191 | + // Blocs actifs : la priorité prime sur tout (tri stable — dans un | |
| 192 | + // même rang, l'ordre par date puis « à moi » est conservé). | |
| 193 | + // « À venir » (active = false) reste chronologique pur. | |
| 194 | + sel.sort_by_key(|t| prio_rank(&t.priority)); | |
| 195 | + } | |
| 186 | 196 | sel.into_iter().map(|t| card(t)).collect() |
| 187 | 197 | }; |
| 188 | 198 | |
| @@ -227,6 +237,13 @@ pub struct MemberChip { | ||
| 227 | 237 | pub selected: bool, |
| 228 | 238 | } |
| 229 | 239 | |
| 240 | +/// Radio-pill de priorité (Normale pré-cochée : zéro clic pour le cas courant). | |
| 241 | +pub struct PrioChip { | |
| 242 | + pub code: &'static str, | |
| 243 | + pub label: &'static str, | |
| 244 | + pub selected: bool, | |
| 245 | +} | |
| 246 | + | |
| 230 | 247 | #[derive(Template)] |
| 231 | 248 | #[template(path = "todo_form.html")] |
| 232 | 249 | pub struct TodoFormPage { |
| @@ -241,6 +258,7 @@ pub struct TodoFormPage { | ||
| 241 | 258 | /// Motif + noms de jours/mois : le JS des steppers ± 1 jour recalcule |
| 242 | 259 | /// `date_label` sans recharger, sans inventer de texte. |
| 243 | 260 | pub vocab: DateVocab, |
| 261 | + pub prios: Vec<PrioChip>, | |
| 244 | 262 | pub members: Vec<MemberChip>, |
| 245 | 263 | pub error: Option<&'static str>, |
| 246 | 264 | } |
| @@ -302,6 +320,13 @@ async fn form_page( | ||
| 302 | 320 | date, |
| 303 | 321 | date_label: day_label(ctx.lang, date), |
| 304 | 322 | vocab: DateVocab::of(ctx.lang), |
| 323 | + prios: { | |
| 324 | + let current = todo.as_ref().map(|t| t.priority.as_str()).unwrap_or(PRIO_NORMAL); | |
| 325 | + [(PRIO_LOW, tr.prio_low), (PRIO_NORMAL, tr.prio_normal), (PRIO_HIGH, tr.prio_high)] | |
| 326 | + .into_iter() | |
| 327 | + .map(|(code, label)| PrioChip { code, label, selected: code == current }) | |
| 328 | + .collect() | |
| 329 | + }, | |
| 305 | 330 | members: teams::members(&state.pool, ctx.team_id) |
| 306 | 331 | .await? |
| 307 | 332 | .into_iter() |
| @@ -323,6 +348,7 @@ struct TodoForm { | ||
| 323 | 348 | name: String, |
| 324 | 349 | category: Option<Uuid>, |
| 325 | 350 | date: Option<NaiveDate>, |
| 351 | + priority: &'static str, | |
| 326 | 352 | assignees: Vec<Uuid>, |
| 327 | 353 | } |
| 328 | 354 | |
| @@ -332,6 +358,7 @@ fn parse_form(pairs: &[(String, String)]) -> TodoForm { | ||
| 332 | 358 | name: String::new(), |
| 333 | 359 | category: None, |
| 334 | 360 | date: None, |
| 361 | + priority: PRIO_NORMAL, | |
| 335 | 362 | assignees: Vec::new(), |
| 336 | 363 | }; |
| 337 | 364 | for (k, v) in pairs { |
| @@ -340,6 +367,14 @@ fn parse_form(pairs: &[(String, String)]) -> TodoForm { | ||
| 340 | 367 | "nom" => f.name = v.trim().to_string(), |
| 341 | 368 | "categorie" => f.category = v.parse().ok(), |
| 342 | 369 | "date" => f.date = v.parse().ok(), |
| 370 | + // Valeur inconnue → normale (la FK refuserait de toute façon). | |
| 371 | + "priorite" => { | |
| 372 | + f.priority = match v.as_str() { | |
| 373 | + "basse" => PRIO_LOW, | |
| 374 | + "haute" => PRIO_HIGH, | |
| 375 | + _ => PRIO_NORMAL, | |
| 376 | + } | |
| 377 | + } | |
| 343 | 378 | "assigne" => { |
| 344 | 379 | if let Ok(id) = v.parse() { |
| 345 | 380 | f.assignees.push(id); |
| @@ -366,7 +401,7 @@ pub async fn todo_create( | ||
| 366 | 401 | if f.name.is_empty() { |
| 367 | 402 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); |
| 368 | 403 | } |
| 369 | - match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? { | |
| 404 | + match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, f.priority, &f.assignees).await? { | |
| 370 | 405 | 0 => Err(AppError::BadRequest("bad category".into())), |
| 371 | 406 | _ => Ok(Redirect::to("/taches?ok=creee")), |
| 372 | 407 | } |
| @@ -388,7 +423,7 @@ pub async fn todo_update( | ||
| 388 | 423 | if f.name.is_empty() { |
| 389 | 424 | return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet"))); |
| 390 | 425 | } |
| 391 | - match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, &f.assignees).await? { | |
| 426 | + match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, f.priority, &f.assignees).await? { | |
| 392 | 427 | 0 => Err(AppError::NotFound), |
| 393 | 428 | _ => Ok(Redirect::to("/taches?ok=modifiee")), |
| 394 | 429 | } |
| @@ -12,7 +12,7 @@ use uuid::Uuid; | |||
| 12 | use crate::charts::{color_map, fmt_minutes}; | 12 | use crate::charts::{color_map, fmt_minutes}; |
| 13 | use crate::db::categories; | 13 | use crate::db::categories; |
| 14 | use crate::db::teams; | 14 | use crate::db::teams; |
| 15 | -use crate::db::todos::{self, STATE_IN_PROGRESS, STATE_TODO, TodoRow}; | 15 | +use crate::db::todos::{self, PRIO_HIGH, PRIO_LOW, PRIO_NORMAL, STATE_IN_PROGRESS, STATE_TODO, TodoRow, prio_rank}; |
| 16 | use crate::error::AppError; | 16 | use crate::error::AppError; |
| 17 | use crate::i18n::{Tr, p1, p2}; | 17 | use crate::i18n::{Tr, p1, p2}; |
| 18 | use crate::period::{DateVocab, day_label, day_month, paris_today}; | 18 | use crate::period::{DateVocab, day_label, day_month, paris_today}; |
| @@ -49,6 +49,9 @@ pub struct TodoCard { | |||
| 49 | pub assignees: Vec<AsgChip>, | 49 | pub assignees: Vec<AsgChip>, |
| 50 | /// Je suis assigné → liseré sur la carte. | 50 | /// Je suis assigné → liseré sur la carte. |
| 51 | pub for_me: bool, | 51 | pub for_me: bool, |
| 52 | + /// Priorité haute → liseré rouge + symbole (blocs actifs seulement, | ||
| 53 | + /// le bloc « À venir » reste neutre). | ||
| 54 | + pub high: bool, | ||
| 52 | } | 55 | } |
| 53 | 56 | ||
| 54 | #[derive(Template)] | 57 | #[derive(Template)] |
| @@ -157,6 +160,7 @@ pub async fn todos_page( | |||
| 157 | }) | 160 | }) |
| 158 | .collect(), | 161 | .collect(), |
| 159 | for_me, | 162 | for_me, |
| 163 | + high: t.priority == PRIO_HIGH, | ||
| 160 | } | 164 | } |
| 161 | }; | 165 | }; |
| 162 | 166 | ||
| @@ -183,6 +187,12 @@ pub async fn todos_page( | |||
| 183 | // Les miennes en premier, puis les sans-assigné (stable). | 187 | // Les miennes en premier, puis les sans-assigné (stable). |
| 184 | sel.sort_by_key(|t| !assigned_to_me(t)); | 188 | sel.sort_by_key(|t| !assigned_to_me(t)); |
| 185 | } | 189 | } |
| 190 | + if active { | ||
| 191 | + // Blocs actifs : la priorité prime sur tout (tri stable — dans un | ||
| 192 | + // même rang, l'ordre par date puis « à moi » est conservé). | ||
| 193 | + // « À venir » (active = false) reste chronologique pur. | ||
| 194 | + sel.sort_by_key(|t| prio_rank(&t.priority)); | ||
| 195 | + } | ||
| 186 | sel.into_iter().map(|t| card(t)).collect() | 196 | sel.into_iter().map(|t| card(t)).collect() |
| 187 | }; | 197 | }; |
| 188 | 198 | ||
| @@ -227,6 +237,13 @@ pub struct MemberChip { | |||
| 227 | pub selected: bool, | 237 | pub selected: bool, |
| 228 | } | 238 | } |
| 229 | 239 | ||
| 240 | +/// Radio-pill de priorité (Normale pré-cochée : zéro clic pour le cas courant). | ||
| 241 | +pub struct PrioChip { | ||
| 242 | + pub code: &'static str, | ||
| 243 | + pub label: &'static str, | ||
| 244 | + pub selected: bool, | ||
| 245 | +} | ||
| 246 | + | ||
| 230 | #[derive(Template)] | 247 | #[derive(Template)] |
| 231 | #[template(path = "todo_form.html")] | 248 | #[template(path = "todo_form.html")] |
| 232 | pub struct TodoFormPage { | 249 | pub struct TodoFormPage { |
| @@ -241,6 +258,7 @@ pub struct TodoFormPage { | |||
| 241 | /// Motif + noms de jours/mois : le JS des steppers ± 1 jour recalcule | 258 | /// Motif + noms de jours/mois : le JS des steppers ± 1 jour recalcule |
| 242 | /// `date_label` sans recharger, sans inventer de texte. | 259 | /// `date_label` sans recharger, sans inventer de texte. |
| 243 | pub vocab: DateVocab, | 260 | pub vocab: DateVocab, |
| 261 | + pub prios: Vec<PrioChip>, | ||
| 244 | pub members: Vec<MemberChip>, | 262 | pub members: Vec<MemberChip>, |
| 245 | pub error: Option<&'static str>, | 263 | pub error: Option<&'static str>, |
| 246 | } | 264 | } |
| @@ -302,6 +320,13 @@ async fn form_page( | |||
| 302 | date, | 320 | date, |
| 303 | date_label: day_label(ctx.lang, date), | 321 | date_label: day_label(ctx.lang, date), |
| 304 | vocab: DateVocab::of(ctx.lang), | 322 | vocab: DateVocab::of(ctx.lang), |
| 323 | + prios: { | ||
| 324 | + let current = todo.as_ref().map(|t| t.priority.as_str()).unwrap_or(PRIO_NORMAL); | ||
| 325 | + [(PRIO_LOW, tr.prio_low), (PRIO_NORMAL, tr.prio_normal), (PRIO_HIGH, tr.prio_high)] | ||
| 326 | + .into_iter() | ||
| 327 | + .map(|(code, label)| PrioChip { code, label, selected: code == current }) | ||
| 328 | + .collect() | ||
| 329 | + }, | ||
| 305 | members: teams::members(&state.pool, ctx.team_id) | 330 | members: teams::members(&state.pool, ctx.team_id) |
| 306 | .await? | 331 | .await? |
| 307 | .into_iter() | 332 | .into_iter() |
| @@ -323,6 +348,7 @@ struct TodoForm { | |||
| 323 | name: String, | 348 | name: String, |
| 324 | category: Option<Uuid>, | 349 | category: Option<Uuid>, |
| 325 | date: Option<NaiveDate>, | 350 | date: Option<NaiveDate>, |
| 351 | + priority: &'static str, | ||
| 326 | assignees: Vec<Uuid>, | 352 | assignees: Vec<Uuid>, |
| 327 | } | 353 | } |
| 328 | 354 | ||
| @@ -332,6 +358,7 @@ fn parse_form(pairs: &[(String, String)]) -> TodoForm { | |||
| 332 | name: String::new(), | 358 | name: String::new(), |
| 333 | category: None, | 359 | category: None, |
| 334 | date: None, | 360 | date: None, |
| 361 | + priority: PRIO_NORMAL, | ||
| 335 | assignees: Vec::new(), | 362 | assignees: Vec::new(), |
| 336 | }; | 363 | }; |
| 337 | for (k, v) in pairs { | 364 | for (k, v) in pairs { |
| @@ -340,6 +367,14 @@ fn parse_form(pairs: &[(String, String)]) -> TodoForm { | |||
| 340 | "nom" => f.name = v.trim().to_string(), | 367 | "nom" => f.name = v.trim().to_string(), |
| 341 | "categorie" => f.category = v.parse().ok(), | 368 | "categorie" => f.category = v.parse().ok(), |
| 342 | "date" => f.date = v.parse().ok(), | 369 | "date" => f.date = v.parse().ok(), |
| 370 | + // Valeur inconnue → normale (la FK refuserait de toute façon). | ||
| 371 | + "priorite" => { | ||
| 372 | + f.priority = match v.as_str() { | ||
| 373 | + "basse" => PRIO_LOW, | ||
| 374 | + "haute" => PRIO_HIGH, | ||
| 375 | + _ => PRIO_NORMAL, | ||
| 376 | + } | ||
| 377 | + } | ||
| 343 | "assigne" => { | 378 | "assigne" => { |
| 344 | if let Ok(id) = v.parse() { | 379 | if let Ok(id) = v.parse() { |
| 345 | f.assignees.push(id); | 380 | f.assignees.push(id); |
| @@ -366,7 +401,7 @@ pub async fn todo_create( | |||
| 366 | if f.name.is_empty() { | 401 | if f.name.is_empty() { |
| 367 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); | 402 | return Ok(Redirect::to("/taches/nouvelle?err=incomplet")); |
| 368 | } | 403 | } |
| 369 | - match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, &f.assignees).await? { | 404 | + match todos::create(&state.pool, ctx.team_id, ctx.user.id, cat, &f.name, date, f.priority, &f.assignees).await? { |
| 370 | 0 => Err(AppError::BadRequest("bad category".into())), | 405 | 0 => Err(AppError::BadRequest("bad category".into())), |
| 371 | _ => Ok(Redirect::to("/taches?ok=creee")), | 406 | _ => Ok(Redirect::to("/taches?ok=creee")), |
| 372 | } | 407 | } |
| @@ -388,7 +423,7 @@ pub async fn todo_update( | |||
| 388 | if f.name.is_empty() { | 423 | if f.name.is_empty() { |
| 389 | return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet"))); | 424 | return Ok(Redirect::to(&format!("/taches/{id}/modifier?err=incomplet"))); |
| 390 | } | 425 | } |
| 391 | - match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, &f.assignees).await? { | 426 | + match todos::update(&state.pool, ctx.team_id, ctx.user.id, id, cat, &f.name, date, f.priority, &f.assignees).await? { |
| 392 | 0 => Err(AppError::NotFound), | 427 | 0 => Err(AppError::NotFound), |
| 393 | _ => Ok(Redirect::to("/taches?ok=modifiee")), | 428 | _ => Ok(Redirect::to("/taches?ok=modifiee")), |
| 394 | } | 429 | } |
modified
templates/todo_form.html +12 -0 | @@ -44,6 +44,18 @@ | ||
| 44 | 44 | data-months="{{ vocab.months }}" data-today="{{ vocab.today }}" |
| 45 | 45 | data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p> |
| 46 | 46 | |
| 47 | + <div class="field" style="margin-bottom:var(--space-2)"> | |
| 48 | + <label id="todo-prio-label">{{ shell.tr.prio_label }}</label> | |
| 49 | + </div> | |
| 50 | + <div class="chips" role="radiogroup" aria-labelledby="todo-prio-label" style="margin-bottom:var(--space-6)"> | |
| 51 | + {% for p in prios %} | |
| 52 | + <label class="chip"> | |
| 53 | + <input type="radio" name="priorite" value="{{ p.code }}" required {% if p.selected %}checked{% endif %}> | |
| 54 | + {{ p.label }} | |
| 55 | + </label> | |
| 56 | + {% endfor %} | |
| 57 | + </div> | |
| 58 | + | |
| 47 | 59 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 48 | 60 | <label id="todo-asg-label">{{ shell.tr.assign_to }}</label> |
| 49 | 61 | </div> |
| @@ -44,6 +44,18 @@ | |||
| 44 | data-months="{{ vocab.months }}" data-today="{{ vocab.today }}" | 44 | data-months="{{ vocab.months }}" data-today="{{ vocab.today }}" |
| 45 | data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p> | 45 | data-today-suffix="{{ vocab.today_suffix }}">{{ date_label }}</p> |
| 46 | 46 | ||
| 47 | + <div class="field" style="margin-bottom:var(--space-2)"> | ||
| 48 | + <label id="todo-prio-label">{{ shell.tr.prio_label }}</label> | ||
| 49 | + </div> | ||
| 50 | + <div class="chips" role="radiogroup" aria-labelledby="todo-prio-label" style="margin-bottom:var(--space-6)"> | ||
| 51 | + {% for p in prios %} | ||
| 52 | + <label class="chip"> | ||
| 53 | + <input type="radio" name="priorite" value="{{ p.code }}" required {% if p.selected %}checked{% endif %}> | ||
| 54 | + {{ p.label }} | ||
| 55 | + </label> | ||
| 56 | + {% endfor %} | ||
| 57 | + </div> | ||
| 58 | + | ||
| 47 | <div class="field" style="margin-bottom:var(--space-2)"> | 59 | <div class="field" style="margin-bottom:var(--space-2)"> |
| 48 | <label id="todo-asg-label">{{ shell.tr.assign_to }}</label> | 60 | <label id="todo-asg-label">{{ shell.tr.assign_to }}</label> |
| 49 | </div> | 61 | </div> |
modified
templates/todos.html +4 -4 | @@ -24,11 +24,11 @@ | ||
| 24 | 24 | {% else %} |
| 25 | 25 | <div class="todo-grid"> |
| 26 | 26 | {% for t in doing %} |
| 27 | - <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}"> | |
| 27 | + <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}{% if t.high %} prio-haute{% endif %}"> | |
| 28 | 28 | <div class="todo-main"> |
| 29 | 29 | <span class="team-dot" style="background:{{ t.color }}"></span> |
| 30 | 30 | <div style="flex:1;min-width:0"> |
| 31 | - <div class="todo-name">{{ t.name }}</div> | |
| 31 | + <div class="todo-name">{{ t.name }}{% if t.high %} <span class="prio-flag" role="img" aria-label="{{ shell.tr.prio_high_aria }}">‼︎</span>{% endif %}</div> | |
| 32 | 32 | <div class="todo-meta">{{ t.meta }}</div> |
| 33 | 33 | {% if !t.assignees.is_empty() %} |
| 34 | 34 | <div class="asg-chips"> |
| @@ -70,11 +70,11 @@ | ||
| 70 | 70 | {% else %} |
| 71 | 71 | <div class="todo-grid"> |
| 72 | 72 | {% for t in open %} |
| 73 | - <div class="todo-card{% if t.for_me %} forme{% endif %}"> | |
| 73 | + <div class="todo-card{% if t.for_me %} forme{% endif %}{% if t.high %} prio-haute{% endif %}"> | |
| 74 | 74 | <div class="todo-main"> |
| 75 | 75 | <span class="team-dot" style="background:{{ t.color }}"></span> |
| 76 | 76 | <div style="flex:1;min-width:0"> |
| 77 | - <div class="todo-name">{{ t.name }}</div> | |
| 77 | + <div class="todo-name">{{ t.name }}{% if t.high %} <span class="prio-flag" role="img" aria-label="{{ shell.tr.prio_high_aria }}">‼︎</span>{% endif %}</div> | |
| 78 | 78 | <div class="todo-meta{% if t.late %} late{% endif %}">{{ t.meta }}</div> |
| 79 | 79 | {% if !t.assignees.is_empty() %} |
| 80 | 80 | <div class="asg-chips"> |
| @@ -24,11 +24,11 @@ | |||
| 24 | {% else %} | 24 | {% else %} |
| 25 | <div class="todo-grid"> | 25 | <div class="todo-grid"> |
| 26 | {% for t in doing %} | 26 | {% for t in doing %} |
| 27 | - <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}"> | 27 | + <div class="todo-card{% if t.other %} other{% endif %}{% if t.for_me %} forme{% endif %}{% if t.high %} prio-haute{% endif %}"> |
| 28 | <div class="todo-main"> | 28 | <div class="todo-main"> |
| 29 | <span class="team-dot" style="background:{{ t.color }}"></span> | 29 | <span class="team-dot" style="background:{{ t.color }}"></span> |
| 30 | <div style="flex:1;min-width:0"> | 30 | <div style="flex:1;min-width:0"> |
| 31 | - <div class="todo-name">{{ t.name }}</div> | 31 | + <div class="todo-name">{{ t.name }}{% if t.high %} <span class="prio-flag" role="img" aria-label="{{ shell.tr.prio_high_aria }}">‼︎</span>{% endif %}</div> |
| 32 | <div class="todo-meta">{{ t.meta }}</div> | 32 | <div class="todo-meta">{{ t.meta }}</div> |
| 33 | {% if !t.assignees.is_empty() %} | 33 | {% if !t.assignees.is_empty() %} |
| 34 | <div class="asg-chips"> | 34 | <div class="asg-chips"> |
| @@ -70,11 +70,11 @@ | |||
| 70 | {% else %} | 70 | {% else %} |
| 71 | <div class="todo-grid"> | 71 | <div class="todo-grid"> |
| 72 | {% for t in open %} | 72 | {% for t in open %} |
| 73 | - <div class="todo-card{% if t.for_me %} forme{% endif %}"> | 73 | + <div class="todo-card{% if t.for_me %} forme{% endif %}{% if t.high %} prio-haute{% endif %}"> |
| 74 | <div class="todo-main"> | 74 | <div class="todo-main"> |
| 75 | <span class="team-dot" style="background:{{ t.color }}"></span> | 75 | <span class="team-dot" style="background:{{ t.color }}"></span> |
| 76 | <div style="flex:1;min-width:0"> | 76 | <div style="flex:1;min-width:0"> |
| 77 | - <div class="todo-name">{{ t.name }}</div> | 77 | + <div class="todo-name">{{ t.name }}{% if t.high %} <span class="prio-flag" role="img" aria-label="{{ shell.tr.prio_high_aria }}">‼︎</span>{% endif %}</div> |
| 78 | <div class="todo-meta{% if t.late %} late{% endif %}">{{ t.meta }}</div> | 78 | <div class="todo-meta{% if t.late %} late{% endif %}">{{ t.meta }}</div> |
| 79 | {% if !t.assignees.is_empty() %} | 79 | {% if !t.assignees.is_empty() %} |
| 80 | <div class="asg-chips"> | 80 | <div class="asg-chips"> |