Files
Bonsai-webapp/src/app/issues/issue-detail/issue-detail.html
T
2026-05-30 07:38:08 +02:00

394 lines
16 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.id) {
<li>
<button
type="button"
class="dropdown-item d-flex align-items-center gap-2"
[class.active]="issue.status === status.id"
(click)="selectStatus(status.id)"
>
<span
class="status-badge"
[style.background]="status.bg"
[style.color]="status.color"
>{{ 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)"
[disabled]="isChildOfEpic"
>
<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 class="row g-2">
<div class="col-6">
<div class="field-label-row">
<span class="field-label mb-0">Date de début</span>
@if (hasDependencies) {
<select
class="date-mode-select"
aria-label="Mode date de début"
[ngModel]="issue.startDateMode"
(ngModelChange)="startDateModeValue = $event"
>
<option value="calculated">Calculée</option>
<option value="forced">Forcée</option>
</select>
}
</div>
@if (issue.startDateMode === 'calculated') {
<input
aria-label="Date de début calculée"
class="form-control form-control-sm bg-body-secondary"
type="date"
[ngModel]="calculatedStartDate"
readonly
/>
@if (startDateModeWarning) {
<div class="text-warning small mt-1">{{ startDateModeWarning }}</div>
}
} @else {
<input
aria-label="Date de début"
class="form-control form-control-sm"
[class.is-invalid]="!!dateValidationError"
type="date"
[(ngModel)]="issue.startDate"
(blur)="onStartDateBlur()"
/>
}
</div>
<div class="col-6">
<label class="field-label">Date de fin</label>
<input
aria-label="Date de fin"
class="form-control form-control-sm"
type="date"
[ngModel]="issue.endDate"
readonly
/>
</div>
@if (dateValidationError) {
<div class="col-12 text-danger small">{{ dateValidationError }}</div>
}
</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>
@if (isEpicIssue) {
<div class="form-control form-control-sm bg-body-secondary text-secondary">{{ epicEstimatedTime !== null ? epicEstimatedTime : '—' }}</div>
} @else {
<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 d-flex align-items-center justify-content-between">
<span>Description</span>
@if (!editingDescription) {
<button type="button" class="description-action-btn" (click)="startEditDescription()">Modifier</button>
}
</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"
(keydown.escape)="cancelEditDescription()"
(paste)="onDescriptionPaste($event)"
></textarea>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-primary" (click)="saveDescription()">Enregistrer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelEditDescription()">Annuler</button>
</div>
} @else {
<div
class="description-preview"
(click)="startEditDescription()"
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>
}
<!-- Gantt de l'Epic -->
@if (isEpicIssue && !isNewIssueRoute) {
<div class="card shadow-sm mb-3">
<div class="card-header section-header">Diagramme Gantt</div>
<div class="card-body">
<app-gantt-diagram [tasks]="epicGanttTasks" />
</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>
}