Refacto issue detail
This commit is contained in:
Generated
+13
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"marked": "^18.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -6181,6 +6182,18 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
||||
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.3",
|
||||
"marked": "^18.0.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -2,352 +2,172 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.875rem;
|
||||
/* Section headers */
|
||||
.section-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #6b7280;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #2563eb;
|
||||
color: #ffffff;
|
||||
padding: 0.65rem 1rem;
|
||||
/* Field labels */
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #374151;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
background-color: #1d4ed8;
|
||||
/* Title input */
|
||||
.title-input::placeholder {
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* More menu */
|
||||
.more-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.more-button {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #ffffff;
|
||||
color: #374151;
|
||||
padding: 0.65rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more-button:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
top: calc(100% + 0.35rem);
|
||||
min-width: 10rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
|
||||
padding: 0.25rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.more-menu-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.5rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more-menu-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #059669;
|
||||
color: #ffffff;
|
||||
padding: 0.65rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #047857;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background-color: #ffffff;
|
||||
color: #374151;
|
||||
padding: 0.65rem 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dependency-list {
|
||||
list-style: none;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.dependency-item {
|
||||
display: flex;
|
||||
/* Dependency badges */
|
||||
.dep-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.4rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.dependency-label {
|
||||
font-size: 0.875rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dependency-remove {
|
||||
flex-shrink: 0;
|
||||
.dep-id {
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dep-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.15rem;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dependency-remove:hover {
|
||||
.dep-remove:hover {
|
||||
color: #b91c1c;
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.dependency-add-btn {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dependency-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dependency-select {
|
||||
.dep-select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.epic-issues-card {
|
||||
margin-top: 1rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.epic-issues-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.epic-issues-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.epic-issues-header span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.epic-empty {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.epic-issues-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.epic-issue-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.epic-issue-item strong,
|
||||
.epic-issue-item p {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.epic-issue-item p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.epic-issue-item span {
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
/* Description */
|
||||
.description-textarea {
|
||||
min-height: 40rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
th {
|
||||
width: 220px;
|
||||
background-color: #f9fafb;
|
||||
.description-preview {
|
||||
min-height: 7rem;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.9rem;
|
||||
color: #374151;
|
||||
cursor: text;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.description-preview:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.description-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
tr:last-child th,
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
.markdown-body :is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.markdown-body h1 { font-size: 1.4rem; }
|
||||
.markdown-body h2 { font-size: 1.2rem; }
|
||||
.markdown-body h3 { font-size: 1rem; }
|
||||
|
||||
.header-meta {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.status-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
width: 40%;
|
||||
}
|
||||
.markdown-body p {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.4rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.markdown-body > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -1,172 +1,194 @@
|
||||
<!-- suppress HtmlUnknownAttribute -->
|
||||
<header class="page-header mb-4">
|
||||
<div>
|
||||
<h1>Detail de l'issue</h1>
|
||||
<p>Informations de creation et de suivi de l'issue.</p>
|
||||
|
||||
<!-- 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]="'badge ' + typeBadgeClass">{{ issue.type }}</span>
|
||||
<span class="text-secondary fw-semibold small">#{{ issue.id }}</span>
|
||||
</div>
|
||||
<div class="header-meta d-flex align-items-start gap-3 flex-wrap justify-content-end">
|
||||
<div class="status-inline d-flex align-items-center gap-2 bg-white border rounded-3 px-3 py-2 shadow-sm">
|
||||
<span class="status-label">Status</span>
|
||||
<select
|
||||
aria-label="Status"
|
||||
class="status-select form-select form-select-sm w-auto"
|
||||
[ngModel]="issue.status"
|
||||
(ngModelChange)="updateStatus($event)"
|
||||
>
|
||||
@for (status of statusOptions; track status) {
|
||||
<option [value]="status">{{ status }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="header-actions d-flex gap-2 flex-wrap justify-content-end">
|
||||
<div class="more-wrapper">
|
||||
<button type="button" class="more-button btn btn-outline-secondary btn-sm dropdown-toggle" (click)="toggleMoreMenu()">More</button>
|
||||
|
||||
@if (moreMenuOpen) {
|
||||
<div class="more-menu dropdown-menu show position-absolute end-0 mt-2 p-1 shadow">
|
||||
<button type="button" class="more-menu-item delete-action dropdown-item text-danger" (click)="deleteIssue()">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select
|
||||
aria-label="Status"
|
||||
class="form-select form-select-sm w-auto"
|
||||
[ngModel]="issue.status"
|
||||
(ngModelChange)="updateStatus($event)"
|
||||
>
|
||||
@for (status of statusOptions; track status) {
|
||||
<option [value]="status">{{ status }}</option>
|
||||
}
|
||||
</select>
|
||||
<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>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<section class="detail-card card shadow-sm" aria-label="Informations de l'issue">
|
||||
<table class="table table-borderless align-middle mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<td>{{ issue.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<td>
|
||||
<input aria-label="Nom" class="form-control form-control-sm" type="text" [(ngModel)]="issue.name" (blur)="saveIssue()" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<td>
|
||||
<!-- Titre -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<input
|
||||
aria-label="Titre"
|
||||
class="title-input form-control border-0 shadow-none p-0 fw-semibold fs-5"
|
||||
type="text"
|
||||
placeholder="Titre de l'issue..."
|
||||
[(ngModel)]="issue.name"
|
||||
(blur)="saveIssue()"
|
||||
/>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@if (!isEpicIssue) {
|
||||
<tr>
|
||||
<th>Epic</th>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<th>Depend de</th>
|
||||
<td>
|
||||
@if (dependencyIds.length > 0) {
|
||||
<ul class="dependency-list">
|
||||
@for (depId of dependencyIds; track depId) {
|
||||
<li class="dependency-item">
|
||||
<span class="dependency-label">#{{ depId }} - {{ resolveDependency(depId)?.name || 'Sans nom' }}</span>
|
||||
<button type="button" class="dependency-remove" (click)="removeDependency(depId)" title="Supprimer">×</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@if (showAddDependency) {
|
||||
<div class="dependency-add-row">
|
||||
<select aria-label="Choisir une dépendance" class="form-select form-select-sm dependency-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" (click)="confirmAddDependency()" [disabled]="selectedCandidateId === null">Ajouter</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="cancelAddDependency()">Annuler</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dependency-add-btn" (click)="openAddDependency()">+ Ajouter une dépendance</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Assignee</th>
|
||||
<td>
|
||||
<input aria-label="Assignee" class="form-control form-control-sm" type="text" [(ngModel)]="issue.assignee" (blur)="saveIssue()" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date d'echeance</th>
|
||||
<td>
|
||||
<input aria-label="Date d'échéance" class="form-control form-control-sm" type="date" [(ngModel)]="issue.dueDate" (blur)="saveIssue()" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Temps estimé</th>
|
||||
<td>
|
||||
<input aria-label="Temps estimé" class="form-control form-control-sm" type="number" min="0" step="0.5" [(ngModel)]="estimatedTimeValue" (blur)="saveIssue()" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>
|
||||
<textarea aria-label="Description" class="form-control form-control-sm" rows="4" [(ngModel)]="issue.description" (blur)="saveIssue()"></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Priorite</th>
|
||||
<td>
|
||||
</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="Basse">Basse</option>
|
||||
<option value="Moyenne">Moyenne</option>
|
||||
<option value="Haute">Haute</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Progression</th>
|
||||
<td>
|
||||
<input aria-label="Progression" class="form-control form-control-sm" type="number" min="0" max="100" [(ngModel)]="issue.progress" (blur)="saveIssue()" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@if (isEpicIssue) {
|
||||
<section class="epic-issues-card card shadow-sm mt-4" aria-label="Issues composant cet epic">
|
||||
<div class="epic-issues-header card-header d-flex align-items-center justify-content-between">
|
||||
<h2 class="h5 mb-0">Issues composant cet Epic</h2>
|
||||
<span class="badge text-bg-primary">{{ composedIssues.length }}</span>
|
||||
</div>
|
||||
@if (!isEpicIssue) {
|
||||
<div>
|
||||
<label class="field-label">Epic</label>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (composedIssues.length === 0) {
|
||||
<div class="card-body">
|
||||
<p class="epic-empty mb-0 text-secondary">Aucune issue ne compose encore cet Epic.</p>
|
||||
<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="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>
|
||||
<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()"
|
||||
></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 {
|
||||
<ul class="epic-issues-list list-group list-group-flush">
|
||||
<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) {
|
||||
<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="epic-issue-item list-group-item d-flex justify-content-between align-items-start gap-3">
|
||||
<div class="grow">
|
||||
<strong>#{{ composedIssue.id }} - {{ composedIssue.name || 'Sans nom' }}</strong>
|
||||
<p class="mb-0 text-secondary">{{ composedIssue.type }} · {{ composedIssue.status }}</p>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center gap-3 py-3">
|
||||
<div>
|
||||
<p class="mb-0 fw-semibold">#{{ composedIssue.id }} – {{ composedIssue.name || 'Sans nom' }}</p>
|
||||
<p class="mb-0 text-secondary small">{{ composedIssue.type }} · {{ composedIssue.status }}</p>
|
||||
</div>
|
||||
<span class="text-secondary fw-semibold text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
|
||||
<span class="text-secondary small text-nowrap">{{ composedIssue.assignee || 'Non assigné' }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { marked } from 'marked';
|
||||
import { IssueEntity, IssuesStore } from '../issues.store';
|
||||
|
||||
@Component({
|
||||
@@ -13,6 +15,7 @@ export class IssueDetail {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly issuesStore = inject(IssuesStore);
|
||||
private readonly sanitizer = inject(DomSanitizer);
|
||||
private readonly isNewIssueRoute = this.route.snapshot.routeConfig?.path === 'issues/new';
|
||||
|
||||
protected issue: IssueEntity = this.buildIssue();
|
||||
@@ -20,6 +23,7 @@ export class IssueDetail {
|
||||
protected moreMenuOpen = false;
|
||||
protected showAddDependency = false;
|
||||
protected selectedCandidateId: number | null = null;
|
||||
protected editingDescription = false;
|
||||
|
||||
protected readonly statusOptions: IssueEntity['status'][] = [
|
||||
'draft',
|
||||
@@ -107,6 +111,23 @@ export class IssueDetail {
|
||||
return this.issueTypeValue === 'Epic';
|
||||
}
|
||||
|
||||
protected get descriptionHtml(): SafeHtml {
|
||||
const html = marked.parse(this.issue.description || '') as string;
|
||||
return this.sanitizer.bypassSecurityTrustHtml(html);
|
||||
}
|
||||
|
||||
protected get typeBadgeClass(): string {
|
||||
const map: Record<IssueEntity['type'], string> = {
|
||||
Bug: 'text-bg-danger',
|
||||
Study: 'text-bg-secondary',
|
||||
Story: 'text-bg-success',
|
||||
Task: 'text-bg-primary',
|
||||
'Technical Story': 'text-bg-warning',
|
||||
Epic: 'text-bg-info',
|
||||
};
|
||||
return map[this.issueTypeValue] ?? 'text-bg-secondary';
|
||||
}
|
||||
|
||||
protected saveIssue(): void {
|
||||
this.issuesStore.upsert(this.issue);
|
||||
if (this.isNewIssueRoute) {
|
||||
|
||||
Reference in New Issue
Block a user