Files
Bonsai-webapp/src/app/issues/issue-detail/issue-detail.html
T
2026-05-28 05:57:33 +02:00

313 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- suppress HtmlUnknownAttribute -->
<!-- Top bar -->
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
<div class="d-flex align-items-center gap-2">
<span class="type-icon" [style.background]="typeIcon(issue.type).bg" [title]="issue.type">{{ typeIcon(issue.type).letter }}</span>
<span class="text-secondary fw-semibold small">#{{ issue.id }}</span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="status-split-wrapper">
<div class="btn-group">
<button
type="button"
class="status-main-btn"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
[style.border-color]="statusBadge(issue.status).color"
>{{ statusBadge(issue.status).label }}</button>
<button
type="button"
class="status-toggle-btn dropdown-toggle dropdown-toggle-split"
[style.background]="statusBadge(issue.status).bg"
[style.color]="statusBadge(issue.status).color"
[style.border-color]="statusBadge(issue.status).color"
(click)="toggleStatusMenu()"
aria-label="Changer le statut"
><span class="visually-hidden">Changer le statut</span></button>
</div>
@if (statusMenuOpen) {
<div class="status-backdrop" (click)="closeStatusMenu()"></div>
<ul class="status-dropdown dropdown-menu show">
@for (status of statusOptions; track status) {
<li>
<button
type="button"
class="dropdown-item d-flex align-items-center gap-2"
[class.active]="issue.status === status"
(click)="selectStatus(status)"
>
<span
class="status-badge"
[style.background]="statusBadge(status).bg"
[style.color]="statusBadge(status).color"
>{{ statusBadge(status).label }}</span>
</button>
</li>
}
</ul>
}
</div>
@if (!isNewIssueRoute) {
<div class="more-wrapper">
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="toggleMoreMenu()">···</button>
@if (moreMenuOpen) {
<div class="more-menu dropdown-menu show">
<button type="button" class="dropdown-item text-danger" (click)="deleteIssue()">Supprimer</button>
</div>
}
</div>
}
</div>
</div>
<!-- Titre -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<input
#titleInput="ngModel"
aria-label="Titre"
class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
[class.is-invalid]="titleInput.invalid && titleInput.touched"
type="text"
placeholder="Titre de l'issue..."
required
[(ngModel)]="issue.name"
(blur)="saveIssue()"
/>
@if (titleInput.invalid && titleInput.touched) {
<div class="text-danger small mt-1">Le titre est obligatoire.</div>
}
</div>
</div>
<!-- Détails + Planning -->
<div class="row g-3 mb-3">
<div class="col-md-5">
<div class="card shadow-sm h-100">
<div class="card-header section-header">Détails</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="field-label">Type</label>
<select aria-label="Type" class="form-select form-select-sm" [(ngModel)]="issueTypeValue" (change)="saveIssue()">
@for (type of typeOptions; track type) {
<option [value]="type">{{ type }}</option>
}
</select>
</div>
<div>
<label class="field-label">Priorité</label>
<select aria-label="Priorité" class="form-select form-select-sm" [(ngModel)]="issue.priority" (change)="saveIssue()">
<option value="TRES_HAUTE">↑↑ Très haute</option>
<option value="HAUTE">↑ Haute</option>
<option value="MOYENNE"> Moyenne</option>
<option value="BASSE">↓ Basse</option>
<option value="TRES_FAIBLE">↓↓ Très faible</option>
</select>
</div>
@if (!isEpicIssue) {
<div>
<label class="field-label">Epic</label>
<div class="d-flex gap-2 align-items-center">
<select aria-label="Epic" class="form-select form-select-sm" [(ngModel)]="issue.epic" (change)="saveIssue()">
<option value=""></option>
@for (epicIssue of epicIssues; track epicIssue.id) {
<option [value]="epicIssue.name">{{ epicIssue.name }}</option>
}
</select>
@if (epicIssueId !== null) {
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToEpic()" title="Voir l'Epic"></button>
}
</div>
</div>
<div>
<label class="field-label">Milestone</label>
<div class="d-flex gap-2 align-items-center">
<select aria-label="Milestone" class="form-select form-select-sm" [ngModel]="currentMilestoneId" (ngModelChange)="onMilestoneChange($event)">
<option [ngValue]="null"></option>
@for (m of milestones(); track m.id) {
<option [ngValue]="m.id">{{ m.name }}</option>
}
</select>
@if (currentMilestoneId !== null) {
<button type="button" class="btn btn-sm btn-outline-secondary flex-shrink-0" (click)="navigateToMilestone()" title="Voir le Milestone"></button>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="col-md-7">
<div class="card shadow-sm h-100">
<div class="card-header section-header">Planning</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<label class="field-label">Assignee</label>
<input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" />
</div>
<div>
<label class="field-label">Date d'échéance</label>
<input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" />
</div>
<div class="row g-2">
<div [class]="isEpicIssue ? 'col-12' : 'col-6'">
<label class="field-label">Temps estimé (h)</label>
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" />
</div>
@if (!isEpicIssue) {
<div class="col-6">
<label class="field-label">Progression (%)</label>
<input aria-label="Progression" class="form-control form-control-sm" type="number" min="0" max="100" [(ngModel)]="issue.progress" (blur)="saveIssue()" />
</div>
}
</div>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Description</div>
<div class="card-body">
@if (editingDescription) {
<textarea
aria-label="Description"
class="form-control border-0 shadow-none p-0 description-textarea"
placeholder="Ajouter une description..."
[(ngModel)]="issue.description"
(blur)="editingDescription = false; saveIssue()"
(paste)="onDescriptionPaste($event)"
></textarea>
} @else {
<div
class="description-preview"
(click)="editingDescription = true"
title="Cliquer pour éditer"
>
@if (issue.description) {
<div class="markdown-body" [innerHTML]="descriptionHtml"></div>
} @else {
<span class="description-placeholder">Ajouter une description...</span>
}
</div>
}
</div>
</div>
<!-- Dépendances -->
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Dépendances</div>
<div class="card-body">
@if (dependencyIds.length > 0) {
<div class="d-flex flex-wrap gap-2 mb-3">
@for (depId of dependencyIds; track depId) {
<span class="dep-badge">
<span class="dep-id">#{{ depId }}</span>
{{ resolveDependency(depId)?.name || 'Sans nom' }}
<button type="button" class="dep-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
</span>
}
</div>
}
@if (showAddDependency) {
<div class="d-flex gap-2 flex-wrap">
<select aria-label="Choisir une dépendance" class="form-select form-select-sm dep-select" [(ngModel)]="selectedCandidateId">
<option [ngValue]="null">Choisir une issue...</option>
@for (candidate of availableCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddDependency()" [disabled]="selectedCandidateId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddDependency()">Annuler</button>
</div>
} @else {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="openAddDependency()">+ Ajouter une dépendance</button>
}
</div>
</div>
<!-- Issues de l'epic -->
@if (isEpicIssue && !isNewIssueRoute) {
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center justify-content-between section-header">
<span>Issues composant cet Epic</span>
<span class="badge text-bg-primary">{{ composedIssues.length }}</span>
</div>
@if (composedIssues.length === 0) {
<div class="card-body">
<p class="text-secondary mb-0 small">Aucune issue ne compose encore cet Epic.</p>
</div>
} @else {
<ul class="list-group list-group-flush">
@for (composedIssue of composedIssues; track composedIssue.id) {
<li
class="list-group-item d-flex justify-content-between align-items-center gap-3 py-3 composed-issue-item"
(click)="openComposedIssue(composedIssue.id)"
>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="type-icon" [style.background]="typeIcon(composedIssue.type).bg" [title]="composedIssue.type">{{ typeIcon(composedIssue.type).letter }}</span>
<span class="fw-semibold">#{{ composedIssue.id }} {{ composedIssue.name || 'Sans nom' }}</span>
</div>
<div class="d-flex align-items-center gap-3 flex-shrink-0">
<span
[style.color]="priorityDisplay(composedIssue.priority).color"
[title]="priorityDisplay(composedIssue.priority).label"
style="font-weight:700; font-size:1rem; letter-spacing:-1px;"
>{{ priorityDisplay(composedIssue.priority).symbol }}</span>
<span class="text-secondary small text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
</div>
</li>
}
</ul>
}
<div class="card-footer bg-white">
@if (showCreateInEpic) {
<div class="d-flex gap-2 flex-wrap">
<input
aria-label="Titre de la nouvelle issue"
class="form-control form-control-sm dep-select"
type="text"
placeholder="Titre de l'issue..."
[(ngModel)]="newIssueName"
(keydown.enter)="confirmCreateInEpic()"
(keydown.escape)="cancelCreateInEpic()"
/>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmCreateInEpic()" [disabled]="!newIssueName.trim()">Créer</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelCreateInEpic()">Annuler</button>
</div>
} @else if (showAddToEpic) {
<div class="d-flex gap-2 flex-wrap">
<select aria-label="Choisir une issue à ajouter à l'epic" class="form-select form-select-sm dep-select" [(ngModel)]="selectedEpicCandidateId">
<option [ngValue]="null">Choisir une issue...</option>
@for (candidate of epicCandidates; track candidate.id) {
<option [ngValue]="candidate.id">#{{ candidate.id }} {{ candidate.name || 'Sans nom' }}</option>
}
</select>
<button type="button" class="btn btn-sm btn-primary text-nowrap" (click)="confirmAddToEpic()" [disabled]="selectedEpicCandidateId === null">Ajouter</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" (click)="cancelAddToEpic()">Annuler</button>
</div>
} @else {
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-primary" (click)="openCreateInEpic()">+ Créer une issue</button>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="openAddToEpic()">Ajouter une existante</button>
</div>
}
</div>
</div>
}
<!-- Commentaires -->
@if (!isNewIssueRoute) {
<app-issue-comments [issueId]="issue.id" />
}
@if (isNewIssueRoute) {
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-outline-secondary" (click)="cancelCreation()">Annuler</button>
<button type="button" class="btn btn-primary" (click)="saveIssue(true)" [disabled]="!issue.name.trim()">Créer l'issue</button>
</div>
}