313 lines
14 KiB
HTML
313 lines
14 KiB
HTML
<!-- 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>
|
||
}
|