Add after-sales returns and claims with PayU refunds and guest magic links.
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2623664504
commit
f41d12b64f
41 files changed
+5319
-9
No files matched your search
@@ -46,6 +46,13 @@ export const routes: Routes = [
|
||||
(m) => m.OrdersAdminComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'after-sales',
|
||||
loadComponent: () =>
|
||||
import('./features/after-sales/after-sales-admin.component').then(
|
||||
(m) => m.AfterSalesAdminComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'inventory',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -8,6 +8,42 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'nav.analytics': 'Analytika',
|
||||
'nav.products': 'Produkty',
|
||||
'nav.orders': 'Objednávky',
|
||||
'nav.afterSales': 'Reklamace',
|
||||
'afterSales.title': 'Reklamace a vrácení',
|
||||
'afterSales.purpose':
|
||||
'Kontrola žádostí. Potvrďte refundaci PayU, výměnu nebo zamítnutí.',
|
||||
'afterSales.filterStatus': 'Stav',
|
||||
'afterSales.colCreated': 'Vytvořeno',
|
||||
'afterSales.colOrder': 'Objednávka',
|
||||
'afterSales.colCustomer': 'Zákazník',
|
||||
'afterSales.colType': 'Typ',
|
||||
'afterSales.open': 'Otevřít',
|
||||
'afterSales.empty': 'Žádné žádosti.',
|
||||
'afterSales.lines': 'Položky',
|
||||
'afterSales.customerNote': 'Poznámka zákazníka',
|
||||
'afterSales.photos': 'Fotky',
|
||||
'afterSales.resolution': 'Rozhodnutí',
|
||||
'afterSales.resolveTitle': 'Rozhodnout',
|
||||
'afterSales.adminNote': 'Poznámka admina',
|
||||
'afterSales.refundAmount': 'Částka refundace',
|
||||
'afterSales.confirmResolve': 'Potvrdit',
|
||||
'afterSales.errorLoad': 'Nepodařilo se načíst žádosti.',
|
||||
'afterSales.errorResolve': 'Nepodařilo se uzavřít žádost.',
|
||||
'afterSales.type.Withdrawal': 'Vrácení / odstoupení',
|
||||
'afterSales.type.Defect': 'Vada',
|
||||
'afterSales.type.MissingPart': 'Chybějící díl',
|
||||
'afterSales.type.WrongItem': 'Špatný produkt',
|
||||
'afterSales.type.Other': 'Jiné',
|
||||
'afterSales.status.Submitted': 'Podáno',
|
||||
'afterSales.status.InReview': 'V řešení',
|
||||
'afterSales.status.Approved': 'Schváleno',
|
||||
'afterSales.status.Rejected': 'Zamítnuto',
|
||||
'afterSales.status.Resolved': 'Vyřízeno',
|
||||
'afterSales.resolution.Refund': 'Refundace (PayU)',
|
||||
'afterSales.resolution.Replacement': 'Poslat náhradu',
|
||||
'afterSales.resolution.SendMissingPart': 'Poslat chybějící díl',
|
||||
'afterSales.resolution.Reject': 'Zamítnout',
|
||||
'afterSales.resolution.Other': 'Jiné',
|
||||
'nav.promotions': 'Akce',
|
||||
'nav.discounts': 'Akce',
|
||||
'nav.newsletter': 'Newsletter',
|
||||
|
||||
@@ -8,6 +8,42 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'nav.analytics': 'Analysen',
|
||||
'nav.products': 'Produkte',
|
||||
'nav.orders': 'Bestellungen',
|
||||
'nav.afterSales': 'After-Sales',
|
||||
'afterSales.title': 'After-Sales',
|
||||
'afterSales.purpose':
|
||||
'Retouren und Reklamationen prüfen. Rückerstattung, Ersatz oder Ablehnung bestätigen.',
|
||||
'afterSales.filterStatus': 'Status',
|
||||
'afterSales.colCreated': 'Erstellt',
|
||||
'afterSales.colOrder': 'Bestellung',
|
||||
'afterSales.colCustomer': 'Kunde',
|
||||
'afterSales.colType': 'Typ',
|
||||
'afterSales.open': 'Öffnen',
|
||||
'afterSales.empty': 'Keine Anfragen.',
|
||||
'afterSales.lines': 'Positionen',
|
||||
'afterSales.customerNote': 'Kundennotiz',
|
||||
'afterSales.photos': 'Fotos',
|
||||
'afterSales.resolution': 'Entscheidung',
|
||||
'afterSales.resolveTitle': 'Entscheiden',
|
||||
'afterSales.adminNote': 'Admin-Notiz',
|
||||
'afterSales.refundAmount': 'Erstattungsbetrag',
|
||||
'afterSales.confirmResolve': 'Bestätigen',
|
||||
'afterSales.errorLoad': 'Anfragen konnten nicht geladen werden.',
|
||||
'afterSales.errorResolve': 'Anfrage konnte nicht abgeschlossen werden.',
|
||||
'afterSales.type.Withdrawal': 'Widerruf / Rückgabe',
|
||||
'afterSales.type.Defect': 'Defekt',
|
||||
'afterSales.type.MissingPart': 'Fehlendes Teil',
|
||||
'afterSales.type.WrongItem': 'Falscher Artikel',
|
||||
'afterSales.type.Other': 'Sonstiges',
|
||||
'afterSales.status.Submitted': 'Eingereicht',
|
||||
'afterSales.status.InReview': 'In Prüfung',
|
||||
'afterSales.status.Approved': 'Genehmigt',
|
||||
'afterSales.status.Rejected': 'Abgelehnt',
|
||||
'afterSales.status.Resolved': 'Erledigt',
|
||||
'afterSales.resolution.Refund': 'Erstattung (PayU)',
|
||||
'afterSales.resolution.Replacement': 'Ersatz senden',
|
||||
'afterSales.resolution.SendMissingPart': 'Fehlendes Teil senden',
|
||||
'afterSales.resolution.Reject': 'Ablehnen',
|
||||
'afterSales.resolution.Other': 'Sonstiges',
|
||||
'nav.promotions': 'Aktionen',
|
||||
'nav.discounts': 'Aktionen',
|
||||
'nav.newsletter': 'Newsletter',
|
||||
|
||||
@@ -8,6 +8,42 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'nav.analytics': 'Analytics',
|
||||
'nav.products': 'Products',
|
||||
'nav.orders': 'Orders',
|
||||
'nav.afterSales': 'After-sales',
|
||||
'afterSales.title': 'After-sales',
|
||||
'afterSales.purpose':
|
||||
'Review return and claim requests. Confirm refunds, replacements, or rejections.',
|
||||
'afterSales.filterStatus': 'Status',
|
||||
'afterSales.colCreated': 'Created',
|
||||
'afterSales.colOrder': 'Order',
|
||||
'afterSales.colCustomer': 'Customer',
|
||||
'afterSales.colType': 'Type',
|
||||
'afterSales.open': 'Open',
|
||||
'afterSales.empty': 'No after-sales requests.',
|
||||
'afterSales.lines': 'Items',
|
||||
'afterSales.customerNote': 'Customer note',
|
||||
'afterSales.photos': 'Photos',
|
||||
'afterSales.resolution': 'Resolution',
|
||||
'afterSales.resolveTitle': 'Decide',
|
||||
'afterSales.adminNote': 'Admin note',
|
||||
'afterSales.refundAmount': 'Refund amount',
|
||||
'afterSales.confirmResolve': 'Confirm',
|
||||
'afterSales.errorLoad': 'Could not load after-sales requests.',
|
||||
'afterSales.errorResolve': 'Could not resolve request.',
|
||||
'afterSales.type.Withdrawal': 'Withdrawal / return',
|
||||
'afterSales.type.Defect': 'Defect',
|
||||
'afterSales.type.MissingPart': 'Missing part',
|
||||
'afterSales.type.WrongItem': 'Wrong item',
|
||||
'afterSales.type.Other': 'Other',
|
||||
'afterSales.status.Submitted': 'Submitted',
|
||||
'afterSales.status.InReview': 'In review',
|
||||
'afterSales.status.Approved': 'Approved',
|
||||
'afterSales.status.Rejected': 'Rejected',
|
||||
'afterSales.status.Resolved': 'Resolved',
|
||||
'afterSales.resolution.Refund': 'Refund (PayU)',
|
||||
'afterSales.resolution.Replacement': 'Send replacement',
|
||||
'afterSales.resolution.SendMissingPart': 'Send missing part',
|
||||
'afterSales.resolution.Reject': 'Reject',
|
||||
'afterSales.resolution.Other': 'Other',
|
||||
'nav.promotions': 'Promotions',
|
||||
'nav.discounts': 'Promotions',
|
||||
'nav.newsletter': 'Newsletter',
|
||||
|
||||
@@ -8,6 +8,42 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'nav.analytics': 'Analityka',
|
||||
'nav.products': 'Produkty',
|
||||
'nav.orders': 'Zamówienia',
|
||||
'nav.afterSales': 'Reklamacje',
|
||||
'afterSales.title': 'Reklamacje i zwroty',
|
||||
'afterSales.purpose':
|
||||
'Przeglądaj zgłoszenia. Potwierdź zwrot PayU, wymianę lub odrzucenie.',
|
||||
'afterSales.filterStatus': 'Status',
|
||||
'afterSales.colCreated': 'Utworzono',
|
||||
'afterSales.colOrder': 'Zamówienie',
|
||||
'afterSales.colCustomer': 'Klient',
|
||||
'afterSales.colType': 'Typ',
|
||||
'afterSales.open': 'Otwórz',
|
||||
'afterSales.empty': 'Brak zgłoszeń.',
|
||||
'afterSales.lines': 'Pozycje',
|
||||
'afterSales.customerNote': 'Notatka klienta',
|
||||
'afterSales.photos': 'Zdjęcia',
|
||||
'afterSales.resolution': 'Rozstrzygnięcie',
|
||||
'afterSales.resolveTitle': 'Decyzja',
|
||||
'afterSales.adminNote': 'Notatka admina',
|
||||
'afterSales.refundAmount': 'Kwota zwrotu',
|
||||
'afterSales.confirmResolve': 'Potwierdź',
|
||||
'afterSales.errorLoad': 'Nie udało się wczytać zgłoszeń.',
|
||||
'afterSales.errorResolve': 'Nie udało się rozstrzygnąć.',
|
||||
'afterSales.type.Withdrawal': 'Zwrot / odstąpienie',
|
||||
'afterSales.type.Defect': 'Wada',
|
||||
'afterSales.type.MissingPart': 'Brakująca część',
|
||||
'afterSales.type.WrongItem': 'Zły produkt',
|
||||
'afterSales.type.Other': 'Inne',
|
||||
'afterSales.status.Submitted': 'Nowe',
|
||||
'afterSales.status.InReview': 'W trakcie',
|
||||
'afterSales.status.Approved': 'Zatwierdzone',
|
||||
'afterSales.status.Rejected': 'Odrzucone',
|
||||
'afterSales.status.Resolved': 'Zamknięte',
|
||||
'afterSales.resolution.Refund': 'Zwrot (PayU)',
|
||||
'afterSales.resolution.Replacement': 'Wymiana',
|
||||
'afterSales.resolution.SendMissingPart': 'Wyślij brakującą część',
|
||||
'afterSales.resolution.Reject': 'Odrzuć',
|
||||
'afterSales.resolution.Other': 'Inne',
|
||||
'nav.promotions': 'Promocje',
|
||||
'nav.discounts': 'Promocje',
|
||||
'nav.newsletter': 'Newsletter',
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ 'afterSales.title' | translate }}</h1>
|
||||
<p class="page-purpose">{{ 'afterSales.purpose' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 16px">
|
||||
<div class="card-pad flex items-end gap-8" style="flex-wrap: wrap">
|
||||
<div class="field" style="min-width: 180px">
|
||||
<label for="as-status">{{ 'afterSales.filterStatus' | translate }}</label>
|
||||
<select
|
||||
id="as-status"
|
||||
class="select"
|
||||
[(ngModel)]="statusFilter"
|
||||
name="statusFilter"
|
||||
(ngModelChange)="handleFilter()"
|
||||
>
|
||||
<option value="">{{ 'common.all' | translate }}</option>
|
||||
@for (s of statuses; track s) {
|
||||
<option [ngValue]="s">
|
||||
{{ 'afterSales.status.' + enumKey(s) | translate }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<app-admin-skeleton variant="table" [rows]="6" />
|
||||
} @else {
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'afterSales.colCreated' | translate }}</th>
|
||||
<th>{{ 'afterSales.colOrder' | translate }}</th>
|
||||
<th>{{ 'afterSales.colCustomer' | translate }}</th>
|
||||
<th>{{ 'afterSales.colType' | translate }}</th>
|
||||
<th>{{ 'orders.status' | translate }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of items(); track item.id) {
|
||||
<tr>
|
||||
<td>{{ item.createdAt | date: 'yyyy-MM-dd HH:mm' }}</td>
|
||||
<td class="cell-strong">{{ item.orderNumber }}</td>
|
||||
<td>
|
||||
<div class="cell-strong">{{ item.customerName }}</div>
|
||||
<div class="cell-muted">{{ item.customerEmail }}</div>
|
||||
</td>
|
||||
<td>{{ 'afterSales.type.' + enumKey(item.type) | translate }}</td>
|
||||
<td>
|
||||
<span class="badge" [class]="statusBadge(item.status)">
|
||||
{{ 'afterSales.status.' + enumKey(item.status) | translate }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
(click)="openDetail(item)"
|
||||
>
|
||||
{{ 'afterSales.open' | translate }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="cell-muted">
|
||||
{{ 'afterSales.empty' | translate }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-pad">
|
||||
<app-admin-pagination
|
||||
[page]="page()"
|
||||
[pageSize]="pageSize"
|
||||
[totalCount]="totalCount()"
|
||||
(prev)="handlePage(page() - 1)"
|
||||
(next)="handlePage(page() + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selected(); as d) {
|
||||
<div
|
||||
class="admin-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
(click)="closeDetail()"
|
||||
>
|
||||
<div
|
||||
class="admin-modal__panel admin-modal__panel--lg"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="admin-modal__head">
|
||||
<div>
|
||||
<h2>{{ d.orderNumber }}</h2>
|
||||
<p class="page-purpose" style="margin: 0">
|
||||
{{ 'afterSales.type.' + enumKey(d.type) | translate }}
|
||||
·
|
||||
<span class="badge" [class]="statusBadge(d.status)">
|
||||
{{ 'afterSales.status.' + enumKey(d.status) | translate }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-modal__close"
|
||||
(click)="closeDetail()"
|
||||
[attr.aria-label]="'common.close' | translate"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-modal__body stack" style="gap: 16px">
|
||||
<div class="grid grid-2">
|
||||
<div>
|
||||
<div class="cell-muted">{{ 'afterSales.colCustomer' | translate }}</div>
|
||||
<div class="cell-strong">{{ d.customerName }}</div>
|
||||
<div class="cell-muted">{{ d.customerEmail }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cell-muted">{{ 'afterSales.colCreated' | translate }}</div>
|
||||
<div>{{ d.createdAt | date: 'yyyy-MM-dd HH:mm' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>{{ 'afterSales.lines' | translate }}</h2>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'products.name' | translate }}</th>
|
||||
<th>SKU</th>
|
||||
<th>{{ 'orders.qty' | translate }}</th>
|
||||
<th>{{ 'products.price' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of d.lines; track line.orderLineId) {
|
||||
<tr>
|
||||
<td class="cell-strong">{{ line.productName }}</td>
|
||||
<td class="cell-muted">{{ line.sku }}</td>
|
||||
<td>{{ line.quantity }}</td>
|
||||
<td>{{ line.unitPrice | i18nCurrency }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-block" style="border: 0; padding: 0">
|
||||
<h3>{{ 'afterSales.customerNote' | translate }}</h3>
|
||||
<p style="margin: 0; white-space: pre-wrap">
|
||||
{{ d.customerNote || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (d.attachments.length) {
|
||||
<div>
|
||||
<h3 style="margin: 0 0 8px">{{ 'afterSales.photos' | translate }}</h3>
|
||||
<div class="flex gap-8" style="flex-wrap: wrap">
|
||||
@for (att of d.attachments; track att.id) {
|
||||
<a [href]="imageUrl(att.url)" target="_blank" rel="noopener">
|
||||
<img
|
||||
[src]="imageUrl(att.url)"
|
||||
[alt]="att.originalFileName || ''"
|
||||
style="
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (d.resolution) {
|
||||
<div class="section-block" style="border: 0; padding: 0">
|
||||
<h3>{{ 'afterSales.resolution' | translate }}</h3>
|
||||
<p style="margin: 0">
|
||||
{{ 'afterSales.resolution.' + enumKey(d.resolution) | translate }}
|
||||
@if (d.refundAmount != null) {
|
||||
· {{ d.refundAmount | i18nCurrency }}
|
||||
}
|
||||
@if (d.payURefundId) {
|
||||
· {{ d.payURefundId }}
|
||||
}
|
||||
</p>
|
||||
@if (d.adminNote) {
|
||||
<p class="cell-muted" style="margin: 8px 0 0; white-space: pre-wrap">
|
||||
{{ d.adminNote }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isOpen(d)) {
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>{{ 'afterSales.resolveTitle' | translate }}</h2>
|
||||
</div>
|
||||
<div class="card-pad stack" style="gap: 12px">
|
||||
<div class="field">
|
||||
<label for="as-res">{{ 'afterSales.resolution' | translate }}</label>
|
||||
<select
|
||||
id="as-res"
|
||||
class="select"
|
||||
[(ngModel)]="resolution"
|
||||
name="resolution"
|
||||
>
|
||||
<option [ngValue]="'refund'">
|
||||
{{ 'afterSales.resolution.Refund' | translate }}
|
||||
</option>
|
||||
<option [ngValue]="'replacement'">
|
||||
{{ 'afterSales.resolution.Replacement' | translate }}
|
||||
</option>
|
||||
<option [ngValue]="'sendMissingPart'">
|
||||
{{ 'afterSales.resolution.SendMissingPart' | translate }}
|
||||
</option>
|
||||
<option [ngValue]="'other'">
|
||||
{{ 'afterSales.resolution.Other' | translate }}
|
||||
</option>
|
||||
<option [ngValue]="'reject'">
|
||||
{{ 'afterSales.resolution.Reject' | translate }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (resolution === 'refund') {
|
||||
<div class="field">
|
||||
<label for="as-amt">{{ 'afterSales.refundAmount' | translate }}</label>
|
||||
<input
|
||||
id="as-amt"
|
||||
class="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
[(ngModel)]="refundAmount"
|
||||
name="refundAmount"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div class="field">
|
||||
<label for="as-note">{{ 'afterSales.adminNote' | translate }}</label>
|
||||
<textarea
|
||||
id="as-note"
|
||||
class="textarea"
|
||||
rows="3"
|
||||
[(ngModel)]="adminNote"
|
||||
name="adminNote"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
[disabled]="resolving()"
|
||||
(click)="handleResolve()"
|
||||
>
|
||||
{{ 'afterSales.confirmResolve' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { resolveProductImageUrl } from '../../core/product-image.util';
|
||||
import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
|
||||
import { AdminPaginationComponent } from '../../shared/admin-pagination.component';
|
||||
import { ToastService } from '../../shared/toast.service';
|
||||
import {
|
||||
AFTER_SALES_STATUSES,
|
||||
AdminAfterSalesList,
|
||||
AfterSalesRequestDetail,
|
||||
AfterSalesRequestStatus,
|
||||
AfterSalesResolution,
|
||||
afterSalesEnumKey,
|
||||
} from './after-sales.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-after-sales-admin',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
DatePipe,
|
||||
TranslatePipe,
|
||||
I18nCurrencyPipe,
|
||||
AdminSkeletonComponent,
|
||||
AdminPaginationComponent,
|
||||
],
|
||||
templateUrl: './after-sales-admin.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AfterSalesAdminComponent implements OnInit {
|
||||
readonly #api = inject(ApiService);
|
||||
readonly #toast = inject(ToastService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly resolving = signal(false);
|
||||
readonly items = signal<AfterSalesRequestDetail[]>([]);
|
||||
readonly totalCount = signal(0);
|
||||
readonly page = signal(1);
|
||||
readonly pageSize = 20;
|
||||
readonly selected = signal<AfterSalesRequestDetail | null>(null);
|
||||
|
||||
statusFilter: AfterSalesRequestStatus | '' = '';
|
||||
readonly statuses = AFTER_SALES_STATUSES;
|
||||
|
||||
resolution: AfterSalesResolution = 'refund';
|
||||
adminNote = '';
|
||||
refundAmount: number | null = null;
|
||||
|
||||
readonly enumKey = afterSalesEnumKey;
|
||||
|
||||
imageUrl(url: string): string {
|
||||
return resolveProductImageUrl(url);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
const params = new URLSearchParams({
|
||||
page: String(this.page()),
|
||||
pageSize: String(this.pageSize),
|
||||
});
|
||||
if (this.statusFilter) {
|
||||
params.set('status', this.statusFilter);
|
||||
}
|
||||
|
||||
this.#api
|
||||
.get<AdminAfterSalesList>(`/after-sales/admin?${params.toString()}`)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.items.set(res.items);
|
||||
this.totalCount.set(res.totalCount);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.#toast.error('afterSales.errorLoad');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleFilter(): void {
|
||||
this.page.set(1);
|
||||
this.load();
|
||||
}
|
||||
|
||||
handlePage(p: number): void {
|
||||
this.page.set(p);
|
||||
this.load();
|
||||
}
|
||||
|
||||
openDetail(item: AfterSalesRequestDetail): void {
|
||||
this.selected.set(item);
|
||||
this.resolution = 'refund';
|
||||
this.adminNote = '';
|
||||
this.refundAmount = item.lines.reduce(
|
||||
(sum, l) => sum + l.unitPrice * l.quantity,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
closeDetail(): void {
|
||||
this.selected.set(null);
|
||||
}
|
||||
|
||||
statusBadge(status: AfterSalesRequestStatus): string {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
case 'inReview':
|
||||
return 'badge-warning';
|
||||
case 'approved':
|
||||
case 'resolved':
|
||||
return 'badge-success';
|
||||
case 'rejected':
|
||||
return 'badge-danger';
|
||||
default:
|
||||
return 'badge-neutral';
|
||||
}
|
||||
}
|
||||
|
||||
isOpen(item: AfterSalesRequestDetail): boolean {
|
||||
return (
|
||||
item.status === 'submitted' ||
|
||||
item.status === 'inReview' ||
|
||||
item.status === 'approved'
|
||||
);
|
||||
}
|
||||
|
||||
handleResolve(): void {
|
||||
const item = this.selected();
|
||||
if (!item || this.resolving()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resolving.set(true);
|
||||
this.#api
|
||||
.post<AfterSalesRequestDetail>(
|
||||
`/after-sales/admin/${item.id}/resolve`,
|
||||
{
|
||||
resolution: this.resolution,
|
||||
adminNote: this.adminNote || null,
|
||||
refundAmount:
|
||||
this.resolution === 'refund' ? this.refundAmount : null,
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
this.resolving.set(false);
|
||||
this.selected.set(updated);
|
||||
this.#toast.success('common.saved');
|
||||
this.load();
|
||||
},
|
||||
error: (err) => {
|
||||
this.resolving.set(false);
|
||||
const msg =
|
||||
err?.error?.message ||
|
||||
err?.error?.title ||
|
||||
'afterSales.errorResolve';
|
||||
this.#toast.error(
|
||||
typeof msg === 'string' ? msg : 'afterSales.errorResolve',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export type AfterSalesRequestType =
|
||||
| 'withdrawal'
|
||||
| 'defect'
|
||||
| 'missingPart'
|
||||
| 'wrongItem'
|
||||
| 'other';
|
||||
|
||||
export type AfterSalesRequestStatus =
|
||||
| 'submitted'
|
||||
| 'inReview'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'resolved';
|
||||
|
||||
export type AfterSalesResolution =
|
||||
| 'refund'
|
||||
| 'replacement'
|
||||
| 'sendMissingPart'
|
||||
| 'reject'
|
||||
| 'other';
|
||||
|
||||
export interface AfterSalesRequestLine {
|
||||
orderLineId: string;
|
||||
productName: string;
|
||||
sku: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface AfterSalesAttachment {
|
||||
id: string;
|
||||
url: string;
|
||||
originalFileName: string | null;
|
||||
}
|
||||
|
||||
export interface AfterSalesRequestDetail {
|
||||
id: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
type: AfterSalesRequestType;
|
||||
status: AfterSalesRequestStatus;
|
||||
customerNote: string;
|
||||
adminNote: string | null;
|
||||
resolution: AfterSalesResolution | null;
|
||||
refundAmount: number | null;
|
||||
payURefundId: string | null;
|
||||
createdAt: string;
|
||||
resolvedAt: string | null;
|
||||
lines: AfterSalesRequestLine[];
|
||||
attachments: AfterSalesAttachment[];
|
||||
}
|
||||
|
||||
export interface AdminAfterSalesList {
|
||||
items: AfterSalesRequestDetail[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const AFTER_SALES_STATUSES: AfterSalesRequestStatus[] = [
|
||||
'submitted',
|
||||
'inReview',
|
||||
'approved',
|
||||
'rejected',
|
||||
'resolved',
|
||||
];
|
||||
|
||||
/** Map API camelCase enum to i18n PascalCase suffix. */
|
||||
export function afterSalesEnumKey(value: string): string {
|
||||
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
||||
}
|
||||
@@ -56,6 +56,15 @@
|
||||
<svg lucideShoppingCart [size]="18" class="ico" aria-hidden="true"></svg>
|
||||
{{ 'nav.orders' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/after-sales"
|
||||
routerLinkActive="is-active"
|
||||
class="nav-link"
|
||||
(click)="closeSidebar()"
|
||||
>
|
||||
<svg lucidePackage [size]="18" class="ico" aria-hidden="true"></svg>
|
||||
{{ 'nav.afterSales' | translate }}
|
||||
</a>
|
||||
<a
|
||||
routerLink="/inventory"
|
||||
routerLinkActive="is-active"
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using Asp.Versioning;
|
||||
using iLED.Application.AfterSales;
|
||||
using iLED.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace iLED.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/after-sales")]
|
||||
public sealed class AfterSalesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("orders/{orderId:guid}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetOrder(
|
||||
Guid orderId,
|
||||
[FromQuery] string? token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetAfterSalesOrderQuery(orderId, token),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("uploads")]
|
||||
[AllowAnonymous]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> Upload(
|
||||
[FromForm] Guid orderId,
|
||||
[FromForm] string? token,
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { message = "File required." });
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await mediator.Send(
|
||||
new UploadAfterSalesImageCommand(
|
||||
orderId,
|
||||
token,
|
||||
stream,
|
||||
file.FileName),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("requests")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Create(
|
||||
[FromBody] CreateAfterSalesRequestBody body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateAfterSalesRequestCommand(
|
||||
body.OrderId,
|
||||
body.Token,
|
||||
body.Type,
|
||||
body.CustomerNote ?? string.Empty,
|
||||
body.Lines ?? [],
|
||||
body.AttachmentUrls ?? []),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("requests/mine")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetMine(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetMyAfterSalesRequestsQuery(),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("admin")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> AdminList(
|
||||
[FromQuery] AfterSalesRequestStatus? status,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 30,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetAdminAfterSalesQuery(status, page, pageSize),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("admin/{id:guid}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> AdminDetail(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new GetAdminAfterSalesDetailQuery(id),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("admin/{id:guid}/resolve")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Resolve(
|
||||
Guid id,
|
||||
[FromBody] ResolveAfterSalesBody body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new ResolveAfterSalesRequestCommand(
|
||||
id,
|
||||
body.Resolution,
|
||||
body.AdminNote,
|
||||
body.RefundAmount),
|
||||
cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateAfterSalesRequestBody
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
public string? Token { get; set; }
|
||||
|
||||
public AfterSalesRequestType Type { get; set; }
|
||||
|
||||
public string? CustomerNote { get; set; }
|
||||
|
||||
public List<AfterSalesLineInput>? Lines { get; set; }
|
||||
|
||||
public List<string>? AttachmentUrls { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResolveAfterSalesBody
|
||||
{
|
||||
public AfterSalesResolution Resolution { get; set; }
|
||||
|
||||
public string? AdminNote { get; set; }
|
||||
|
||||
public decimal? RefundAmount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
using iLED.Application.Common.Exceptions;
|
||||
using iLED.Application.Common.Interfaces;
|
||||
using iLED.Domain.AfterSales;
|
||||
using iLED.Domain.Enums;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace iLED.Application.AfterSales;
|
||||
|
||||
public static class AfterSalesRules
|
||||
{
|
||||
public static readonly TimeSpan WithdrawalWindow = TimeSpan.FromDays(14);
|
||||
public static readonly TimeSpan ClaimWindow = TimeSpan.FromDays(365);
|
||||
public const int MaxAttachments = 5;
|
||||
|
||||
public static readonly OrderStatus[] EligibleStatuses =
|
||||
[
|
||||
OrderStatus.Paid,
|
||||
OrderStatus.Processing,
|
||||
OrderStatus.Shipped,
|
||||
OrderStatus.Delivered,
|
||||
];
|
||||
|
||||
public static readonly AfterSalesRequestStatus[] OpenStatuses =
|
||||
[
|
||||
AfterSalesRequestStatus.Submitted,
|
||||
AfterSalesRequestStatus.InReview,
|
||||
AfterSalesRequestStatus.Approved,
|
||||
];
|
||||
}
|
||||
|
||||
public sealed record AfterSalesLineInput(Guid OrderLineId, int Quantity);
|
||||
|
||||
public sealed record CreateAfterSalesRequestCommand(
|
||||
Guid OrderId,
|
||||
string? AccessToken,
|
||||
AfterSalesRequestType Type,
|
||||
string CustomerNote,
|
||||
IReadOnlyList<AfterSalesLineInput> Lines,
|
||||
IReadOnlyList<string> AttachmentUrls) : IRequest<AfterSalesRequestDto>;
|
||||
|
||||
public sealed record UploadAfterSalesImageCommand(
|
||||
Guid OrderId,
|
||||
string? AccessToken,
|
||||
Stream ImageStream,
|
||||
string FileName) : IRequest<AfterSalesUploadResult>;
|
||||
|
||||
public sealed record AfterSalesUploadResult(string Url);
|
||||
|
||||
public sealed record GetAfterSalesOrderQuery(
|
||||
Guid OrderId,
|
||||
string? AccessToken) : IRequest<AfterSalesOrderDto>;
|
||||
|
||||
public sealed record GetMyAfterSalesRequestsQuery
|
||||
: IRequest<IReadOnlyList<AfterSalesRequestSummaryDto>>;
|
||||
|
||||
public sealed record GetAdminAfterSalesQuery(
|
||||
AfterSalesRequestStatus? Status = null,
|
||||
int Page = 1,
|
||||
int PageSize = 30) : IRequest<AdminAfterSalesListDto>;
|
||||
|
||||
public sealed record GetAdminAfterSalesDetailQuery(Guid Id)
|
||||
: IRequest<AfterSalesRequestDetailDto>;
|
||||
|
||||
public sealed record ResolveAfterSalesRequestCommand(
|
||||
Guid Id,
|
||||
AfterSalesResolution Resolution,
|
||||
string? AdminNote,
|
||||
decimal? RefundAmount) : IRequest<AfterSalesRequestDetailDto>;
|
||||
|
||||
public sealed record AfterSalesOrderLineDto(
|
||||
Guid OrderLineId,
|
||||
string ProductName,
|
||||
string VariantColor,
|
||||
string Sku,
|
||||
string? ImageUrl,
|
||||
decimal UnitPrice,
|
||||
int Quantity,
|
||||
int RemainingClaimable);
|
||||
|
||||
public sealed record AfterSalesOrderDto(
|
||||
Guid OrderId,
|
||||
string OrderNumber,
|
||||
OrderStatus Status,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<AfterSalesOrderLineDto> Lines);
|
||||
|
||||
public sealed record AfterSalesRequestLineDto(
|
||||
Guid OrderLineId,
|
||||
string ProductName,
|
||||
string Sku,
|
||||
int Quantity,
|
||||
decimal UnitPrice);
|
||||
|
||||
public sealed record AfterSalesAttachmentDto(
|
||||
Guid Id,
|
||||
string Url,
|
||||
string? OriginalFileName);
|
||||
|
||||
public sealed record AfterSalesRequestDto(
|
||||
Guid Id,
|
||||
Guid OrderId,
|
||||
string OrderNumber,
|
||||
AfterSalesRequestType Type,
|
||||
AfterSalesRequestStatus Status,
|
||||
string CustomerNote,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record AfterSalesRequestSummaryDto(
|
||||
Guid Id,
|
||||
Guid OrderId,
|
||||
string OrderNumber,
|
||||
AfterSalesRequestType Type,
|
||||
AfterSalesRequestStatus Status,
|
||||
AfterSalesResolution? Resolution,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record AfterSalesRequestDetailDto(
|
||||
Guid Id,
|
||||
Guid OrderId,
|
||||
string OrderNumber,
|
||||
string CustomerEmail,
|
||||
string CustomerName,
|
||||
AfterSalesRequestType Type,
|
||||
AfterSalesRequestStatus Status,
|
||||
string CustomerNote,
|
||||
string? AdminNote,
|
||||
AfterSalesResolution? Resolution,
|
||||
decimal? RefundAmount,
|
||||
string? PayURefundId,
|
||||
DateTime CreatedAt,
|
||||
DateTime? ResolvedAt,
|
||||
IReadOnlyList<AfterSalesRequestLineDto> Lines,
|
||||
IReadOnlyList<AfterSalesAttachmentDto> Attachments);
|
||||
|
||||
public sealed record AdminAfterSalesListDto(
|
||||
IReadOnlyList<AfterSalesRequestDetailDto> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize);
|
||||
|
||||
public sealed class GetAfterSalesOrderQueryHandler(
|
||||
IAppDbContext db,
|
||||
IAfterSalesAccessService access)
|
||||
: IRequestHandler<GetAfterSalesOrderQuery, AfterSalesOrderDto>
|
||||
{
|
||||
public async Task<AfterSalesOrderDto> Handle(
|
||||
GetAfterSalesOrderQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await db.Orders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Lines)
|
||||
.Include(o => o.AfterSalesRequests)
|
||||
.ThenInclude(r => r.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
|
||||
?? throw new NotFoundException("Order not found.");
|
||||
|
||||
if (!access.CanAccessOrder(order, request.AccessToken))
|
||||
{
|
||||
throw new ValidationException("Access denied.");
|
||||
}
|
||||
|
||||
var claimed = order.AfterSalesRequests
|
||||
.Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) ||
|
||||
r.Status == AfterSalesRequestStatus.Resolved)
|
||||
.SelectMany(r => r.Lines)
|
||||
.GroupBy(l => l.OrderLineId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
|
||||
|
||||
var lines = order.Lines
|
||||
.OrderBy(l => l.CreatedAt)
|
||||
.Select(l =>
|
||||
{
|
||||
claimed.TryGetValue(l.Id, out var used);
|
||||
return new AfterSalesOrderLineDto(
|
||||
l.Id,
|
||||
l.ProductName,
|
||||
l.VariantColor,
|
||||
l.Sku,
|
||||
l.ImageUrl,
|
||||
l.UnitPrice,
|
||||
l.Quantity,
|
||||
Math.Max(0, l.Quantity - used));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new AfterSalesOrderDto(
|
||||
order.Id,
|
||||
order.OrderNumber,
|
||||
order.Status,
|
||||
order.CreatedAt,
|
||||
lines);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UploadAfterSalesImageCommandHandler(
|
||||
IAppDbContext db,
|
||||
IAfterSalesAccessService access,
|
||||
IAfterSalesImageStorage storage)
|
||||
: IRequestHandler<UploadAfterSalesImageCommand, AfterSalesUploadResult>
|
||||
{
|
||||
public async Task<AfterSalesUploadResult> Handle(
|
||||
UploadAfterSalesImageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await db.Orders
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
|
||||
?? throw new NotFoundException("Order not found.");
|
||||
|
||||
if (!access.CanAccessOrder(order, request.AccessToken))
|
||||
{
|
||||
throw new ValidationException("Access denied.");
|
||||
}
|
||||
|
||||
var url = await storage.SaveAsWebpAsync(
|
||||
request.ImageStream,
|
||||
request.FileName,
|
||||
cancellationToken);
|
||||
|
||||
return new AfterSalesUploadResult(url);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateAfterSalesRequestCommandHandler(
|
||||
IAppDbContext db,
|
||||
IAfterSalesAccessService access,
|
||||
ICurrentUserService currentUser)
|
||||
: IRequestHandler<CreateAfterSalesRequestCommand, AfterSalesRequestDto>
|
||||
{
|
||||
public async Task<AfterSalesRequestDto> Handle(
|
||||
CreateAfterSalesRequestCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await db.Orders
|
||||
.Include(o => o.Lines)
|
||||
.Include(o => o.AfterSalesRequests)
|
||||
.ThenInclude(r => r.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken)
|
||||
?? throw new NotFoundException("Order not found.");
|
||||
|
||||
if (!access.CanAccessOrder(order, request.AccessToken))
|
||||
{
|
||||
throw new ValidationException("Access denied.");
|
||||
}
|
||||
|
||||
if (!AfterSalesRules.EligibleStatuses.Contains(order.Status))
|
||||
{
|
||||
throw new ValidationException(
|
||||
"After-sales is available after the order is paid.");
|
||||
}
|
||||
|
||||
var age = DateTime.UtcNow - order.CreatedAt;
|
||||
var window = request.Type == AfterSalesRequestType.Withdrawal
|
||||
? AfterSalesRules.WithdrawalWindow
|
||||
: AfterSalesRules.ClaimWindow;
|
||||
if (age > window)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"The claim window for this request type has expired.");
|
||||
}
|
||||
|
||||
if (request.Lines is null || request.Lines.Count == 0)
|
||||
{
|
||||
throw new ValidationException("Select at least one item.");
|
||||
}
|
||||
|
||||
var attachments = (request.AttachmentUrls ?? [])
|
||||
.Where(u => !string.IsNullOrWhiteSpace(u))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(AfterSalesRules.MaxAttachments)
|
||||
.ToList();
|
||||
|
||||
var claimed = order.AfterSalesRequests
|
||||
.Where(r => AfterSalesRules.OpenStatuses.Contains(r.Status) ||
|
||||
r.Status == AfterSalesRequestStatus.Resolved)
|
||||
.SelectMany(r => r.Lines)
|
||||
.GroupBy(l => l.OrderLineId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
|
||||
|
||||
var entity = new AfterSalesRequest
|
||||
{
|
||||
OrderId = order.Id,
|
||||
UserId = currentUser.UserId ?? order.UserId,
|
||||
Type = request.Type,
|
||||
Status = AfterSalesRequestStatus.Submitted,
|
||||
CustomerNote = (request.CustomerNote ?? string.Empty).Trim(),
|
||||
};
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
var orderLine = order.Lines.FirstOrDefault(l => l.Id == line.OrderLineId)
|
||||
?? throw new ValidationException("Invalid order line.");
|
||||
|
||||
claimed.TryGetValue(orderLine.Id, out var used);
|
||||
var remaining = orderLine.Quantity - used;
|
||||
if (line.Quantity < 1 || line.Quantity > remaining)
|
||||
{
|
||||
throw new ValidationException(
|
||||
$"Invalid quantity for {orderLine.ProductName}.");
|
||||
}
|
||||
|
||||
entity.Lines.Add(new AfterSalesRequestLine
|
||||
{
|
||||
OrderLineId = orderLine.Id,
|
||||
Quantity = line.Quantity,
|
||||
});
|
||||
}
|
||||
|
||||
var sort = 0;
|
||||
foreach (var url in attachments)
|
||||
{
|
||||
if (!url.StartsWith("/uploads/after-sales/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new ValidationException("Invalid attachment URL.");
|
||||
}
|
||||
|
||||
entity.Attachments.Add(new AfterSalesAttachment
|
||||
{
|
||||
Url = url,
|
||||
SortOrder = sort++,
|
||||
});
|
||||
}
|
||||
|
||||
db.Add(entity);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new AfterSalesRequestDto(
|
||||
entity.Id,
|
||||
order.Id,
|
||||
order.OrderNumber,
|
||||
entity.Type,
|
||||
entity.Status,
|
||||
entity.CustomerNote,
|
||||
entity.CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetMyAfterSalesRequestsQueryHandler(
|
||||
IAppDbContext db,
|
||||
ICurrentUserService currentUser)
|
||||
: IRequestHandler<GetMyAfterSalesRequestsQuery, IReadOnlyList<AfterSalesRequestSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyList<AfterSalesRequestSummaryDto>> Handle(
|
||||
GetMyAfterSalesRequestsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentUser.UserId))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return await db.AfterSalesRequests
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Order)
|
||||
.Where(r => r.UserId == currentUser.UserId ||
|
||||
r.Order.UserId == currentUser.UserId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Select(r => new AfterSalesRequestSummaryDto(
|
||||
r.Id,
|
||||
r.OrderId,
|
||||
r.Order.OrderNumber,
|
||||
r.Type,
|
||||
r.Status,
|
||||
r.Resolution,
|
||||
r.CreatedAt))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetAdminAfterSalesQueryHandler(IAppDbContext db)
|
||||
: IRequestHandler<GetAdminAfterSalesQuery, AdminAfterSalesListDto>
|
||||
{
|
||||
public async Task<AdminAfterSalesListDto> Handle(
|
||||
GetAdminAfterSalesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, request.Page);
|
||||
var pageSize = Math.Clamp(request.PageSize, 1, 100);
|
||||
|
||||
var query = db.AfterSalesRequests
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Lines)
|
||||
.ThenInclude(l => l.OrderLine)
|
||||
.Include(r => r.Attachments)
|
||||
.AsQueryable();
|
||||
|
||||
if (request.Status is { } status)
|
||||
{
|
||||
query = query.Where(r => r.Status == status);
|
||||
}
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var items = await query
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new AdminAfterSalesListDto(
|
||||
items.Select(MapDetail).ToList(),
|
||||
total,
|
||||
page,
|
||||
pageSize);
|
||||
}
|
||||
|
||||
internal static AfterSalesRequestDetailDto MapDetail(AfterSalesRequest r) =>
|
||||
new(
|
||||
r.Id,
|
||||
r.OrderId,
|
||||
r.Order.OrderNumber,
|
||||
r.Order.CustomerEmail,
|
||||
$"{r.Order.CustomerFirstName} {r.Order.CustomerLastName}".Trim(),
|
||||
r.Type,
|
||||
r.Status,
|
||||
r.CustomerNote,
|
||||
r.AdminNote,
|
||||
r.Resolution,
|
||||
r.RefundAmount,
|
||||
r.PayURefundId,
|
||||
r.CreatedAt,
|
||||
r.ResolvedAt,
|
||||
r.Lines.Select(l => new AfterSalesRequestLineDto(
|
||||
l.OrderLineId,
|
||||
l.OrderLine.ProductName,
|
||||
l.OrderLine.Sku,
|
||||
l.Quantity,
|
||||
l.OrderLine.UnitPrice)).ToList(),
|
||||
r.Attachments
|
||||
.OrderBy(a => a.SortOrder)
|
||||
.Select(a => new AfterSalesAttachmentDto(
|
||||
a.Id,
|
||||
a.Url,
|
||||
a.OriginalFileName))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public sealed class GetAdminAfterSalesDetailQueryHandler(IAppDbContext db)
|
||||
: IRequestHandler<GetAdminAfterSalesDetailQuery, AfterSalesRequestDetailDto>
|
||||
{
|
||||
public async Task<AfterSalesRequestDetailDto> Handle(
|
||||
GetAdminAfterSalesDetailQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AfterSalesRequests
|
||||
.AsNoTracking()
|
||||
.Include(r => r.Order)
|
||||
.Include(r => r.Lines)
|
||||
.ThenInclude(l => l.OrderLine)
|
||||
.Include(r => r.Attachments)
|
||||
.FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken)
|
||||
?? throw new NotFoundException("After-sales request not found.");
|
||||
|
||||
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ResolveAfterSalesRequestCommandHandler(
|
||||
IAppDbContext db,
|
||||
IPaymentService payments,
|
||||
ICurrentUserService currentUser)
|
||||
: IRequestHandler<ResolveAfterSalesRequestCommand, AfterSalesRequestDetailDto>
|
||||
{
|
||||
public async Task<AfterSalesRequestDetailDto> Handle(
|
||||
ResolveAfterSalesRequestCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entity = await db.AfterSalesRequests
|
||||
.Include(r => r.Order)
|
||||
.ThenInclude(o => o.Payment)
|
||||
.Include(r => r.Lines)
|
||||
.ThenInclude(l => l.OrderLine)
|
||||
.Include(r => r.Attachments)
|
||||
.FirstOrDefaultAsync(r => r.Id == request.Id, cancellationToken)
|
||||
?? throw new NotFoundException("After-sales request not found.");
|
||||
|
||||
if (entity.Status is AfterSalesRequestStatus.Resolved
|
||||
or AfterSalesRequestStatus.Rejected)
|
||||
{
|
||||
throw new ValidationException("Request is already closed.");
|
||||
}
|
||||
|
||||
entity.AdminNote = request.AdminNote?.Trim();
|
||||
entity.Resolution = request.Resolution;
|
||||
entity.ResolvedByAdminId = currentUser.UserId;
|
||||
entity.ResolvedAt = DateTime.UtcNow;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
if (request.Resolution == AfterSalesResolution.Reject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entity.AdminNote))
|
||||
{
|
||||
throw new ValidationException("Admin note is required to reject.");
|
||||
}
|
||||
|
||||
entity.Status = AfterSalesRequestStatus.Rejected;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
|
||||
}
|
||||
|
||||
if (request.Resolution == AfterSalesResolution.Refund)
|
||||
{
|
||||
var defaultAmount = entity.Lines.Sum(
|
||||
l => l.OrderLine.UnitPrice * l.Quantity);
|
||||
var amount = request.RefundAmount ?? defaultAmount;
|
||||
if (amount <= 0 || amount > entity.Order.Total)
|
||||
{
|
||||
throw new ValidationException("Invalid refund amount.");
|
||||
}
|
||||
|
||||
var payment = entity.Order.Payment;
|
||||
if (payment is null ||
|
||||
string.IsNullOrWhiteSpace(payment.ExternalId))
|
||||
{
|
||||
throw new ValidationException(
|
||||
"No PayU payment found for this order.");
|
||||
}
|
||||
|
||||
if (entity.Order.PaymentMethod != PaymentMethod.PayU)
|
||||
{
|
||||
throw new ValidationException(
|
||||
"Automated refunds are only available for PayU orders.");
|
||||
}
|
||||
|
||||
var refund = await payments.RefundPaymentAsync(
|
||||
new PaymentRefundRequest(
|
||||
payment.ExternalId,
|
||||
amount,
|
||||
$"After-sales {entity.Id} · {entity.Order.OrderNumber}"),
|
||||
cancellationToken);
|
||||
|
||||
if (!refund.Success)
|
||||
{
|
||||
throw new ValidationException(
|
||||
refund.ErrorMessage ?? "PayU refund failed.");
|
||||
}
|
||||
|
||||
entity.RefundAmount = amount;
|
||||
entity.PayURefundId = refund.RefundId;
|
||||
entity.Status = AfterSalesRequestStatus.Resolved;
|
||||
|
||||
var fullyRefunded = amount >= entity.Order.Total;
|
||||
payment.Status = fullyRefunded
|
||||
? PaymentStatus.Refunded
|
||||
: payment.Status;
|
||||
payment.UpdatedAt = DateTime.UtcNow;
|
||||
if (fullyRefunded)
|
||||
{
|
||||
entity.Order.Status = OrderStatus.Refunded;
|
||||
entity.Order.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
|
||||
}
|
||||
|
||||
entity.Status = AfterSalesRequestStatus.Resolved;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return GetAdminAfterSalesQueryHandler.MapDetail(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using iLED.Domain.Orders;
|
||||
|
||||
namespace iLED.Application.Common.Interfaces;
|
||||
|
||||
public interface IAfterSalesAccessService
|
||||
{
|
||||
/// <summary>Creates a plain token, stores hash+expiry on the order.</summary>
|
||||
string IssueAccessToken(Order order, TimeSpan? lifetime = null);
|
||||
|
||||
bool ValidateAccessToken(Order order, string? plainToken);
|
||||
|
||||
bool CanAccessOrder(Order order, string? plainToken);
|
||||
}
|
||||
|
||||
public interface IAfterSalesEmailService
|
||||
{
|
||||
Task SendAccessLinkAsync(
|
||||
Order order,
|
||||
string plainToken,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace iLED.Application.Common.Interfaces;
|
||||
|
||||
public interface IAfterSalesImageStorage
|
||||
{
|
||||
Task<string> SaveAsWebpAsync(
|
||||
Stream input,
|
||||
string originalFileName,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -28,6 +28,12 @@ public interface IAppDbContext : IApplicationDbContext
|
||||
|
||||
IQueryable<Domain.Orders.Order> Orders { get; }
|
||||
|
||||
IQueryable<Domain.Orders.OrderLine> OrderLines { get; }
|
||||
|
||||
IQueryable<Domain.AfterSales.AfterSalesRequest> AfterSalesRequests { get; }
|
||||
|
||||
IQueryable<Domain.AfterSales.AfterSalesAttachment> AfterSalesAttachments { get; }
|
||||
|
||||
IQueryable<Domain.Payments.Payment> Payments { get; }
|
||||
|
||||
IQueryable<Domain.Shipping.Shipment> Shipments { get; }
|
||||
|
||||
@@ -17,6 +17,13 @@ public interface IPaymentService
|
||||
Task<PaymentStatusResult> GetPaymentStatusAsync(
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refunds a PayU order (full or partial amount in PLN).
|
||||
/// </summary>
|
||||
Task<PaymentRefundResult> RefundPaymentAsync(
|
||||
PaymentRefundRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PaymentInitiationRequest(
|
||||
@@ -49,3 +56,13 @@ public sealed record PaymentStatusResult(
|
||||
bool IsCompleted,
|
||||
bool IsCanceled,
|
||||
string? ErrorMessage);
|
||||
|
||||
public sealed record PaymentRefundRequest(
|
||||
string ExternalId,
|
||||
decimal Amount,
|
||||
string Description);
|
||||
|
||||
public sealed record PaymentRefundResult(
|
||||
bool Success,
|
||||
string? RefundId,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,16 @@
|
||||
using iLED.Domain.Common;
|
||||
|
||||
namespace iLED.Domain.AfterSales;
|
||||
|
||||
public class AfterSalesAttachment : BaseEntity
|
||||
{
|
||||
public Guid AfterSalesRequestId { get; set; }
|
||||
|
||||
public AfterSalesRequest AfterSalesRequest { get; set; } = null!;
|
||||
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public string? OriginalFileName { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using iLED.Domain.Common;
|
||||
using iLED.Domain.Enums;
|
||||
using iLED.Domain.Orders;
|
||||
|
||||
namespace iLED.Domain.AfterSales;
|
||||
|
||||
public class AfterSalesRequest : BaseEntity
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
public Order Order { get; set; } = null!;
|
||||
|
||||
public string? UserId { get; set; }
|
||||
|
||||
public AfterSalesRequestType Type { get; set; }
|
||||
|
||||
public AfterSalesRequestStatus Status { get; set; } =
|
||||
AfterSalesRequestStatus.Submitted;
|
||||
|
||||
public string CustomerNote { get; set; } = string.Empty;
|
||||
|
||||
public string? AdminNote { get; set; }
|
||||
|
||||
public AfterSalesResolution? Resolution { get; set; }
|
||||
|
||||
public decimal? RefundAmount { get; set; }
|
||||
|
||||
public string? PayURefundId { get; set; }
|
||||
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
|
||||
public string? ResolvedByAdminId { get; set; }
|
||||
|
||||
public ICollection<AfterSalesRequestLine> Lines { get; set; } = [];
|
||||
|
||||
public ICollection<AfterSalesAttachment> Attachments { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using iLED.Domain.Common;
|
||||
using iLED.Domain.Orders;
|
||||
|
||||
namespace iLED.Domain.AfterSales;
|
||||
|
||||
public class AfterSalesRequestLine : BaseEntity
|
||||
{
|
||||
public Guid AfterSalesRequestId { get; set; }
|
||||
|
||||
public AfterSalesRequest AfterSalesRequest { get; set; } = null!;
|
||||
|
||||
public Guid OrderLineId { get; set; }
|
||||
|
||||
public OrderLine OrderLine { get; set; } = null!;
|
||||
|
||||
public int Quantity { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace iLED.Domain.Enums;
|
||||
|
||||
public enum AfterSalesRequestType
|
||||
{
|
||||
Withdrawal = 0,
|
||||
Defect = 1,
|
||||
MissingPart = 2,
|
||||
WrongItem = 3,
|
||||
Other = 4,
|
||||
}
|
||||
|
||||
public enum AfterSalesRequestStatus
|
||||
{
|
||||
Submitted = 0,
|
||||
InReview = 1,
|
||||
Approved = 2,
|
||||
Rejected = 3,
|
||||
Resolved = 4,
|
||||
}
|
||||
|
||||
public enum AfterSalesResolution
|
||||
{
|
||||
Refund = 0,
|
||||
Replacement = 1,
|
||||
SendMissingPart = 2,
|
||||
Reject = 3,
|
||||
Other = 4,
|
||||
}
|
||||
@@ -74,8 +74,18 @@ public class Order : AuditableEntity
|
||||
|
||||
public Marketing.DiscountCode? DiscountCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of guest after-sales access token (plain token emailed).
|
||||
/// </summary>
|
||||
public string? AfterSalesAccessTokenHash { get; set; }
|
||||
|
||||
public DateTime? AfterSalesAccessTokenExpiresAt { get; set; }
|
||||
|
||||
public ICollection<OrderLine> Lines { get; set; } = [];
|
||||
|
||||
public ICollection<AfterSales.AfterSalesRequest> AfterSalesRequests { get; set; } =
|
||||
[];
|
||||
|
||||
public Payments.Payment? Payment { get; set; }
|
||||
|
||||
public Shipping.Shipment? Shipment { get; set; }
|
||||
|
||||
@@ -69,6 +69,11 @@ public static class DependencyInjection
|
||||
services.Configure<ProductImageStorageOptions>(
|
||||
configuration.GetSection(ProductImageStorageOptions.SectionName));
|
||||
services.AddScoped<IProductImageStorage, ProductImageStorage>();
|
||||
services.Configure<AfterSalesImageStorageOptions>(
|
||||
configuration.GetSection(AfterSalesImageStorageOptions.SectionName));
|
||||
services.AddScoped<IAfterSalesImageStorage, AfterSalesImageStorage>();
|
||||
services.AddScoped<IAfterSalesAccessService, AfterSalesAccessService>();
|
||||
services.AddScoped<IAfterSalesEmailService, AfterSalesEmailService>();
|
||||
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
|
||||
@@ -357,6 +357,124 @@ namespace iLED.Infrastructure.Migrations
|
||||
b.ToTable("StoreSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AfterSalesRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AfterSalesRequestId");
|
||||
|
||||
b.ToTable("AfterSalesAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AdminNote")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CustomerNote")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("OrderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PayURefundId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<decimal?>("RefundAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("numeric(18,2)");
|
||||
|
||||
b.Property<int?>("Resolution")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ResolvedByAdminId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrderId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("AfterSalesRequests");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AfterSalesRequestId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("OrderLineId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Quantity")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AfterSalesRequestId");
|
||||
|
||||
b.HasIndex("OrderLineId");
|
||||
|
||||
b.ToTable("AfterSalesRequestLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.Cart.Cart", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1306,6 +1424,12 @@ namespace iLED.Infrastructure.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("AfterSalesAccessTokenExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("AfterSalesAccessTokenHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BillingCity")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -1647,6 +1771,47 @@ namespace iLED.Infrastructure.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesAttachment", b =>
|
||||
{
|
||||
b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest")
|
||||
.WithMany("Attachments")
|
||||
.HasForeignKey("AfterSalesRequestId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AfterSalesRequest");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
|
||||
{
|
||||
b.HasOne("iLED.Domain.Orders.Order", "Order")
|
||||
.WithMany("AfterSalesRequests")
|
||||
.HasForeignKey("OrderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Order");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequestLine", b =>
|
||||
{
|
||||
b.HasOne("iLED.Domain.AfterSales.AfterSalesRequest", "AfterSalesRequest")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("AfterSalesRequestId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("iLED.Domain.Orders.OrderLine", "OrderLine")
|
||||
.WithMany()
|
||||
.HasForeignKey("OrderLineId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AfterSalesRequest");
|
||||
|
||||
b.Navigation("OrderLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.Cart.CartItem", b =>
|
||||
{
|
||||
b.HasOne("iLED.Domain.Cart.Cart", "Cart")
|
||||
@@ -2147,6 +2312,13 @@ namespace iLED.Infrastructure.Migrations
|
||||
b.Navigation("Order");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.AfterSales.AfterSalesRequest", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.Cart.Cart", b =>
|
||||
{
|
||||
b.Navigation("Items");
|
||||
@@ -2214,6 +2386,8 @@ namespace iLED.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("iLED.Domain.Orders.Order", b =>
|
||||
{
|
||||
b.Navigation("AfterSalesRequests");
|
||||
|
||||
b.Navigation("Lines");
|
||||
|
||||
b.Navigation("Payment");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using iLED.Domain.Admin;
|
||||
using iLED.Domain.AfterSales;
|
||||
using iLED.Domain.Cart;
|
||||
using iLED.Domain.Catalog;
|
||||
using iLED.Domain.Identity;
|
||||
@@ -48,6 +49,14 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
||||
|
||||
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
|
||||
|
||||
public DbSet<AfterSalesRequest> AfterSalesRequests => Set<AfterSalesRequest>();
|
||||
|
||||
public DbSet<AfterSalesRequestLine> AfterSalesRequestLines =>
|
||||
Set<AfterSalesRequestLine>();
|
||||
|
||||
public DbSet<AfterSalesAttachment> AfterSalesAttachments =>
|
||||
Set<AfterSalesAttachment>();
|
||||
|
||||
public DbSet<Payment> Payments => Set<Payment>();
|
||||
|
||||
public DbSet<Shipment> Shipments => Set<Shipment>();
|
||||
@@ -117,6 +126,17 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
||||
IQueryable<Order> Application.Common.Interfaces.IAppDbContext.Orders =>
|
||||
Orders;
|
||||
|
||||
IQueryable<OrderLine> Application.Common.Interfaces.IAppDbContext.OrderLines =>
|
||||
OrderLines;
|
||||
|
||||
IQueryable<AfterSalesRequest>
|
||||
Application.Common.Interfaces.IAppDbContext.AfterSalesRequests =>
|
||||
AfterSalesRequests;
|
||||
|
||||
IQueryable<AfterSalesAttachment>
|
||||
Application.Common.Interfaces.IAppDbContext.AfterSalesAttachments =>
|
||||
AfterSalesAttachments;
|
||||
|
||||
IQueryable<Payment> Application.Common.Interfaces.IAppDbContext.Payments =>
|
||||
Payments;
|
||||
|
||||
@@ -287,5 +307,39 @@ public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options
|
||||
.WithOne(o => o.Shipment)
|
||||
.HasForeignKey<Shipment>(s => s.OrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.Entity<AfterSalesRequest>()
|
||||
.HasOne(r => r.Order)
|
||||
.WithMany(o => o.AfterSalesRequests)
|
||||
.HasForeignKey(r => r.OrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.Entity<AfterSalesRequest>()
|
||||
.HasIndex(r => r.Status);
|
||||
|
||||
builder.Entity<AfterSalesRequest>()
|
||||
.HasIndex(r => r.OrderId);
|
||||
|
||||
builder.Entity<AfterSalesRequest>()
|
||||
.Property(r => r.RefundAmount)
|
||||
.HasPrecision(18, 2);
|
||||
|
||||
builder.Entity<AfterSalesRequestLine>()
|
||||
.HasOne(l => l.AfterSalesRequest)
|
||||
.WithMany(r => r.Lines)
|
||||
.HasForeignKey(l => l.AfterSalesRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.Entity<AfterSalesRequestLine>()
|
||||
.HasOne(l => l.OrderLine)
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.OrderLineId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.Entity<AfterSalesAttachment>()
|
||||
.HasOne(a => a.AfterSalesRequest)
|
||||
.WithMany(r => r.Attachments)
|
||||
.HasForeignKey(a => a.AfterSalesRequestId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
+2403
File diff suppressed because it is too large.
Load diff
+154
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace iLED.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AfterSalesRequests : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "AfterSalesAccessTokenExpiresAt",
|
||||
table: "Orders",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AfterSalesAccessTokenHash",
|
||||
table: "Orders",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AfterSalesRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrderId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<string>(type: "text", nullable: true),
|
||||
Type = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CustomerNote = table.Column<string>(type: "text", nullable: false),
|
||||
AdminNote = table.Column<string>(type: "text", nullable: true),
|
||||
Resolution = table.Column<int>(type: "integer", nullable: true),
|
||||
RefundAmount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
|
||||
PayURefundId = table.Column<string>(type: "text", nullable: true),
|
||||
ResolvedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
ResolvedByAdminId = table.Column<string>(type: "text", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AfterSalesRequests", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AfterSalesRequests_Orders_OrderId",
|
||||
column: x => x.OrderId,
|
||||
principalTable: "Orders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AfterSalesAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AfterSalesRequestId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Url = table.Column<string>(type: "text", nullable: false),
|
||||
OriginalFileName = table.Column<string>(type: "text", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AfterSalesAttachments", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AfterSalesAttachments_AfterSalesRequests_AfterSalesRequestId",
|
||||
column: x => x.AfterSalesRequestId,
|
||||
principalTable: "AfterSalesRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AfterSalesRequestLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
AfterSalesRequestId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
OrderLineId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Quantity = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AfterSalesRequestLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AfterSalesRequestLines_AfterSalesRequests_AfterSalesRequest~",
|
||||
column: x => x.AfterSalesRequestId,
|
||||
principalTable: "AfterSalesRequests",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AfterSalesRequestLines_OrderLines_OrderLineId",
|
||||
column: x => x.OrderLineId,
|
||||
principalTable: "OrderLines",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AfterSalesAttachments_AfterSalesRequestId",
|
||||
table: "AfterSalesAttachments",
|
||||
column: "AfterSalesRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AfterSalesRequestLines_AfterSalesRequestId",
|
||||
table: "AfterSalesRequestLines",
|
||||
column: "AfterSalesRequestId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AfterSalesRequestLines_OrderLineId",
|
||||
table: "AfterSalesRequestLines",
|
||||
column: "OrderLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AfterSalesRequests_OrderId",
|
||||
table: "AfterSalesRequests",
|
||||
column: "OrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AfterSalesRequests_Status",
|
||||
table: "AfterSalesRequests",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AfterSalesAttachments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AfterSalesRequestLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AfterSalesRequests");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AfterSalesAccessTokenExpiresAt",
|
||||
table: "Orders");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AfterSalesAccessTokenHash",
|
||||
table: "Orders");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using iLED.Application.Common.Interfaces;
|
||||
using iLED.Domain.Orders;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace iLED.Infrastructure.Services;
|
||||
|
||||
public sealed class AfterSalesAccessService(ICurrentUserService currentUser)
|
||||
: IAfterSalesAccessService
|
||||
{
|
||||
public static readonly TimeSpan DefaultLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
public string IssueAccessToken(Order order, TimeSpan? lifetime = null)
|
||||
{
|
||||
var plain = Convert.ToHexString(RandomNumberGenerator.GetBytes(32))
|
||||
.ToLowerInvariant();
|
||||
order.AfterSalesAccessTokenHash = Hash(plain);
|
||||
order.AfterSalesAccessTokenExpiresAt =
|
||||
DateTime.UtcNow.Add(lifetime ?? DefaultLifetime);
|
||||
return plain;
|
||||
}
|
||||
|
||||
public bool ValidateAccessToken(Order order, string? plainToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plainToken) ||
|
||||
string.IsNullOrWhiteSpace(order.AfterSalesAccessTokenHash) ||
|
||||
order.AfterSalesAccessTokenExpiresAt is null ||
|
||||
order.AfterSalesAccessTokenExpiresAt < DateTime.UtcNow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = Hash(plainToken.Trim());
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(hash),
|
||||
Encoding.UTF8.GetBytes(order.AfterSalesAccessTokenHash));
|
||||
}
|
||||
|
||||
public bool CanAccessOrder(Order order, string? plainToken)
|
||||
{
|
||||
if (currentUser.IsAdmin)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentUser.IsAuthenticated &&
|
||||
!string.IsNullOrWhiteSpace(currentUser.UserId) &&
|
||||
string.Equals(
|
||||
order.UserId,
|
||||
currentUser.UserId,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return ValidateAccessToken(order, plainToken);
|
||||
}
|
||||
|
||||
public static string Hash(string plainToken)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(plainToken));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AfterSalesEmailService(
|
||||
IEmailService email,
|
||||
IConfiguration configuration) : IAfterSalesEmailService
|
||||
{
|
||||
public async Task SendAccessLinkAsync(
|
||||
Order order,
|
||||
string plainToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200";
|
||||
var link =
|
||||
$"{shopBase.TrimEnd('/')}/after-sales" +
|
||||
$"?orderId={order.Id}" +
|
||||
$"&token={Uri.EscapeDataString(plainToken)}";
|
||||
|
||||
var subject = $"iLED — return / report issue · {order.OrderNumber}";
|
||||
var body = $"""
|
||||
<p>Hello {System.Net.WebUtility.HtmlEncode(order.CustomerFirstName)},</p>
|
||||
<p>Use the link below to request a return or report a problem with order
|
||||
<strong>{System.Net.WebUtility.HtmlEncode(order.OrderNumber)}</strong>.</p>
|
||||
<p><a href="{link}">Open after-sales form</a></p>
|
||||
<p>This link expires in 30 days. If you did not place this order, ignore this email.</p>
|
||||
""";
|
||||
|
||||
await email.SendAsync(
|
||||
order.CustomerEmail,
|
||||
subject,
|
||||
body,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using iLED.Application.Common.Interfaces;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace iLED.Infrastructure.Services;
|
||||
|
||||
public sealed class AfterSalesImageStorageOptions
|
||||
{
|
||||
public const string SectionName = "AfterSalesImages";
|
||||
|
||||
public string PublicPathPrefix { get; set; } = "/uploads/after-sales";
|
||||
|
||||
public int MaxWidth { get; set; } = 1600;
|
||||
|
||||
public int WebpQuality { get; set; } = 82;
|
||||
|
||||
public long MaxFileSizeBytes { get; set; } = 8 * 1024 * 1024;
|
||||
}
|
||||
|
||||
public sealed class AfterSalesImageStorage(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<AfterSalesImageStorageOptions> options)
|
||||
: IAfterSalesImageStorage
|
||||
{
|
||||
private static readonly HashSet<string> AllowedExtensions =
|
||||
[
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".bmp",
|
||||
];
|
||||
|
||||
private readonly AfterSalesImageStorageOptions _options = options.Value;
|
||||
|
||||
public async Task<string> SaveAsWebpAsync(
|
||||
Stream input,
|
||||
string originalFileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var extension = Path.GetExtension(originalFileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
{
|
||||
throw new Application.Common.Exceptions.ValidationException(
|
||||
"Unsupported file format. Allowed: JPG, PNG, GIF, WebP, BMP.");
|
||||
}
|
||||
|
||||
var webRoot = environment.WebRootPath
|
||||
?? Path.Combine(environment.ContentRootPath, "wwwroot");
|
||||
var uploadDir = Path.Combine(webRoot, "uploads", "after-sales");
|
||||
Directory.CreateDirectory(uploadDir);
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}.webp";
|
||||
var fullPath = Path.Combine(uploadDir, fileName);
|
||||
|
||||
using var image = await Image.LoadAsync(input, cancellationToken);
|
||||
image.Mutate(x => x.AutoOrient());
|
||||
|
||||
if (image.Width > _options.MaxWidth)
|
||||
{
|
||||
image.Mutate(x => x.Resize(new ResizeOptions
|
||||
{
|
||||
Size = new Size(_options.MaxWidth, 0),
|
||||
Mode = ResizeMode.Max,
|
||||
}));
|
||||
}
|
||||
|
||||
var encoder = new WebpEncoder
|
||||
{
|
||||
Quality = _options.WebpQuality,
|
||||
};
|
||||
|
||||
await image.SaveAsync(fullPath, encoder, cancellationToken);
|
||||
|
||||
return $"{_options.PublicPathPrefix}/{fileName}";
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,140 @@ public sealed class PayUPaymentService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentRefundResult> RefundPaymentAsync(
|
||||
PaymentRefundRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var clientId = settings.PayUClientId;
|
||||
var clientSecret = settings.PayUClientSecret;
|
||||
var oauthUrl = settings.PayUOauthUrl;
|
||||
var ordersUrl = settings.PayUOrdersUrl;
|
||||
var useSandbox = settings.PayUUseSandbox;
|
||||
var hasCredentials =
|
||||
!string.IsNullOrWhiteSpace(clientId) &&
|
||||
!string.IsNullOrWhiteSpace(clientSecret) &&
|
||||
!string.IsNullOrWhiteSpace(oauthUrl) &&
|
||||
!string.IsNullOrWhiteSpace(ordersUrl);
|
||||
|
||||
if (!hasCredentials)
|
||||
{
|
||||
if (!useSandbox)
|
||||
{
|
||||
return new PaymentRefundResult(
|
||||
false,
|
||||
null,
|
||||
"Configure PayU credentials in Settings → Integrations.");
|
||||
}
|
||||
|
||||
var mockId = $"REFUND-{Guid.NewGuid():N}";
|
||||
logger.LogWarning(
|
||||
"PayU credentials missing — mocking refund {RefundId} for {ExternalId}",
|
||||
mockId,
|
||||
request.ExternalId);
|
||||
return new PaymentRefundResult(true, mockId, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = await GetAccessTokenAsync(
|
||||
oauthUrl!,
|
||||
clientId!,
|
||||
clientSecret!,
|
||||
cancellationToken);
|
||||
|
||||
var amountMinor = ((int)Math.Round(
|
||||
request.Amount * 100m,
|
||||
MidpointRounding.AwayFromZero))
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var refundUrl =
|
||||
$"{ordersUrl!.TrimEnd('/')}/{Uri.EscapeDataString(request.ExternalId)}/refunds";
|
||||
|
||||
var body = new
|
||||
{
|
||||
refund = new
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(request.Description)
|
||||
? "Customer refund"
|
||||
: request.Description,
|
||||
amount = amountMinor,
|
||||
},
|
||||
};
|
||||
|
||||
var client = httpClientFactory.CreateClient(nameof(PayUPaymentService));
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, refundUrl);
|
||||
httpRequest.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", token);
|
||||
httpRequest.Content = new StringContent(
|
||||
JsonSerializer.Serialize(body),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await client.SendAsync(
|
||||
httpRequest,
|
||||
cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError(
|
||||
"PayU refund failed: {Status} {Body}",
|
||||
(int)response.StatusCode,
|
||||
responseBody);
|
||||
return new PaymentRefundResult(
|
||||
false,
|
||||
null,
|
||||
"PayU refund failed.");
|
||||
}
|
||||
|
||||
string? refundId = null;
|
||||
if (!string.IsNullOrWhiteSpace(responseBody))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
if (doc.RootElement.TryGetProperty("refund", out var refundEl) &&
|
||||
refundEl.TryGetProperty("refundId", out var idEl))
|
||||
{
|
||||
refundId = idEl.GetString();
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty("orderId", out var oid))
|
||||
{
|
||||
refundId = oid.GetString();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// ignore parse errors; HTTP success is enough
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"PayU refund created for {ExternalId}: {RefundId} amount={Amount}",
|
||||
request.ExternalId,
|
||||
refundId,
|
||||
request.Amount);
|
||||
|
||||
return new PaymentRefundResult(
|
||||
true,
|
||||
refundId ?? $"REFUND-{Guid.NewGuid():N}",
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(
|
||||
ex,
|
||||
"PayU refund error for {ExternalId}",
|
||||
request.ExternalId);
|
||||
return new PaymentRefundResult(
|
||||
false,
|
||||
null,
|
||||
"PayU refund failed.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentWebhookResult> HandleWebhookAsync(
|
||||
string payload,
|
||||
string signature,
|
||||
|
||||
@@ -15,7 +15,9 @@ public sealed class OrderService(
|
||||
Persistence.ApplicationDbContext db,
|
||||
ICartService cartService,
|
||||
ICurrentUserService currentUser,
|
||||
IPromotionRewardService promotionRewards) : IOrderService
|
||||
IPromotionRewardService promotionRewards,
|
||||
IAfterSalesAccessService afterSalesAccess,
|
||||
IAfterSalesEmailService afterSalesEmail) : IOrderService
|
||||
{
|
||||
private const decimal VatRate = 0.23m;
|
||||
private const decimal CourierDeliveryCost = 15.99m;
|
||||
@@ -142,6 +144,7 @@ public sealed class OrderService(
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
var afterSalesToken = afterSalesAccess.IssueAccessToken(order);
|
||||
db.Orders.Add(order);
|
||||
|
||||
if (appliedCode is not null)
|
||||
@@ -167,6 +170,19 @@ public sealed class OrderService(
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await afterSalesEmail.SendAccessLinkAsync(
|
||||
order,
|
||||
afterSalesToken,
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Order already persisted; email failures must not roll back checkout.
|
||||
}
|
||||
|
||||
return MapOrder(order);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,17 @@ export const routes: Routes = [
|
||||
descriptionKey: 'meta.order.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'after-sales',
|
||||
loadComponent: () =>
|
||||
import('./features/after-sales/after-sales.component').then(
|
||||
(m) => m.AfterSalesComponent,
|
||||
),
|
||||
data: {
|
||||
titleKey: 'meta.afterSales.title',
|
||||
descriptionKey: 'meta.afterSales.description',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
@@ -211,6 +222,7 @@ export const routes: Routes = [
|
||||
{ path: 'kontakt', redirectTo: 'contact', pathMatch: 'full' },
|
||||
{ path: 'dostawa', redirectTo: 'shipping', pathMatch: 'full' },
|
||||
{ path: 'zwroty', redirectTo: 'returns', pathMatch: 'full' },
|
||||
{ path: 'reklamacje', redirectTo: 'after-sales', pathMatch: 'full' },
|
||||
{
|
||||
path: '**',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -57,6 +57,13 @@ export class ApiService {
|
||||
headers: this.#getHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Multipart upload; auth/locale headers only (no JSON content-type). */
|
||||
uploadForm<T>(path: string, form: FormData): Observable<T> {
|
||||
return this.#http.post<T>(`${API_URL}${path}`, form, {
|
||||
headers: this.#getHeaders(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -15,6 +15,9 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'meta.checkout.description': 'Dokončete objednávku.',
|
||||
'meta.order.title': 'Potvrzení objednávky — iLED',
|
||||
'meta.order.description': 'Děkujeme za objednávku.',
|
||||
'meta.afterSales.title': 'Vrácení / reklamace — iLED',
|
||||
'meta.afterSales.description':
|
||||
'Požádejte o vrácení nebo nahlaste problém s objednávkou.',
|
||||
'meta.login.title': 'Přihlášení — iLED',
|
||||
'meta.login.description': 'Přihlaste se nebo vytvořte účet.',
|
||||
'meta.favorites.title': 'Oblíbené — iLED',
|
||||
@@ -360,6 +363,37 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'account.logout': 'Odhlásit se',
|
||||
'account.myOrders': 'Moje objednávky',
|
||||
'account.details': 'Detail',
|
||||
'account.afterSales': 'Vrácení / reklamace',
|
||||
'checkout.afterSales': 'Vrácení / reklamace',
|
||||
'afterSales.title': 'Vrácení nebo nahlášení problému',
|
||||
'afterSales.subtitle':
|
||||
'Vyberte položky z objednávky, popište problém a volitelně přidejte fotky.',
|
||||
'afterSales.selectItems': 'Vyberte položky',
|
||||
'afterSales.remaining': 'Ještě lze reklamovat',
|
||||
'afterSales.qty': 'Kusů',
|
||||
'afterSales.requestType': 'O co jde?',
|
||||
'afterSales.type.Withdrawal': 'Vrácení / odstoupení',
|
||||
'afterSales.type.Defect': 'Vada / poškození',
|
||||
'afterSales.type.MissingPart': 'Chybějící díl',
|
||||
'afterSales.type.WrongItem': 'Špatný produkt',
|
||||
'afterSales.type.Other': 'Jiné',
|
||||
'afterSales.note': 'Popis',
|
||||
'afterSales.notePlaceholder': 'Co je špatně…',
|
||||
'afterSales.photos': 'Fotky',
|
||||
'afterSales.photosHint':
|
||||
'Až 5 fotek. Na telefonu můžete použít fotoaparát.',
|
||||
'afterSales.uploading': 'Nahrávání…',
|
||||
'afterSales.maxPhotos': 'Maximálně 5 fotek.',
|
||||
'afterSales.uploadError': 'Fotku se nepodařilo nahrát.',
|
||||
'afterSales.submit': 'Odeslat žádost',
|
||||
'afterSales.submitting': 'Odesílání…',
|
||||
'afterSales.submitError': 'Žádost se nepodařilo odeslat.',
|
||||
'afterSales.successTitle': 'Žádost přijata',
|
||||
'afterSales.successBody':
|
||||
'Prohlédneme ji a ozveme se e-mailem.',
|
||||
'afterSales.missingOrder':
|
||||
'Otevřete stránku z e-mailu objednávky nebo účtu.',
|
||||
'afterSales.loadError': 'Objednávku se nepodařilo načíst.',
|
||||
'account.noOrders': 'Žádné objednávky.',
|
||||
'account.loginPrompt': ' pro zobrazení účtu.',
|
||||
'favorites.title': 'Oblíbené produkty',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'meta.checkout.description': 'Bestellung abschließen.',
|
||||
'meta.order.title': 'Bestellbestätigung — iLED',
|
||||
'meta.order.description': 'Vielen Dank für Ihre Bestellung.',
|
||||
'meta.afterSales.title': 'Rückgabe / Reklamation — iLED',
|
||||
'meta.afterSales.description':
|
||||
'Rückgabe oder Problem zu Ihrer Bestellung melden.',
|
||||
'meta.login.title': 'Anmelden — iLED',
|
||||
'meta.login.description': 'Anmelden oder Konto erstellen.',
|
||||
'meta.favorites.title': 'Favoriten — iLED',
|
||||
@@ -361,6 +364,37 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'account.logout': 'Abmelden',
|
||||
'account.myOrders': 'Meine Bestellungen',
|
||||
'account.details': 'Details',
|
||||
'account.afterSales': 'Rückgabe / melden',
|
||||
'checkout.afterSales': 'Rückgabe / Problem melden',
|
||||
'afterSales.title': 'Rückgabe oder Problem melden',
|
||||
'afterSales.subtitle':
|
||||
'Wählen Sie Artikel, beschreiben Sie das Problem und fügen Sie optional Fotos hinzu.',
|
||||
'afterSales.selectItems': 'Artikel wählen',
|
||||
'afterSales.remaining': 'Noch meldebar',
|
||||
'afterSales.qty': 'Menge',
|
||||
'afterSales.requestType': 'Worum geht es?',
|
||||
'afterSales.type.Withdrawal': 'Rückgabe / Widerruf',
|
||||
'afterSales.type.Defect': 'Defekt / Beschädigung',
|
||||
'afterSales.type.MissingPart': 'Fehlendes Teil',
|
||||
'afterSales.type.WrongItem': 'Falscher Artikel',
|
||||
'afterSales.type.Other': 'Sonstiges',
|
||||
'afterSales.note': 'Beschreibung',
|
||||
'afterSales.notePlaceholder': 'Was ist falsch…',
|
||||
'afterSales.photos': 'Fotos',
|
||||
'afterSales.photosHint':
|
||||
'Bis zu 5 Fotos. Am Handy können Sie die Kamera nutzen.',
|
||||
'afterSales.uploading': 'Hochladen…',
|
||||
'afterSales.maxPhotos': 'Maximal 5 Fotos.',
|
||||
'afterSales.uploadError': 'Foto konnte nicht hochgeladen werden.',
|
||||
'afterSales.submit': 'Anfrage senden',
|
||||
'afterSales.submitting': 'Senden…',
|
||||
'afterSales.submitError': 'Anfrage konnte nicht gesendet werden.',
|
||||
'afterSales.successTitle': 'Anfrage erhalten',
|
||||
'afterSales.successBody':
|
||||
'Wir prüfen sie und melden uns per E-Mail.',
|
||||
'afterSales.missingOrder':
|
||||
'Öffnen Sie diese Seite aus der Bestell-E-Mail oder dem Konto.',
|
||||
'afterSales.loadError': 'Bestellung konnte nicht geladen werden.',
|
||||
'account.noOrders': 'Keine Bestellungen.',
|
||||
'account.loginPrompt': ', um Ihr Konto zu sehen.',
|
||||
'favorites.title': 'Lieblingsprodukte',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'meta.checkout.description': 'Complete your order.',
|
||||
'meta.order.title': 'Order confirmation — iLED',
|
||||
'meta.order.description': 'Thank you for your order.',
|
||||
'meta.afterSales.title': 'Return / report issue — iLED',
|
||||
'meta.afterSales.description':
|
||||
'Request a return or report a problem with your order.',
|
||||
'meta.login.title': 'Sign in — iLED',
|
||||
'meta.login.description': 'Sign in or create an account.',
|
||||
'meta.favorites.title': 'Favorites — iLED',
|
||||
@@ -356,7 +359,37 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'account.logout': 'Sign out',
|
||||
'account.myOrders': 'My orders',
|
||||
'account.details': 'Details',
|
||||
'account.afterSales': 'Return / report',
|
||||
'account.noOrders': 'No orders yet.',
|
||||
'checkout.afterSales': 'Return / report issue',
|
||||
'afterSales.title': 'Return or report an issue',
|
||||
'afterSales.subtitle':
|
||||
'Select items from your order, describe the problem, and optionally add photos.',
|
||||
'afterSales.selectItems': 'Select items',
|
||||
'afterSales.remaining': 'Still claimable',
|
||||
'afterSales.qty': 'Qty',
|
||||
'afterSales.requestType': 'What is this about?',
|
||||
'afterSales.type.Withdrawal': 'Return / withdrawal',
|
||||
'afterSales.type.Defect': 'Defect / damage',
|
||||
'afterSales.type.MissingPart': 'Missing part',
|
||||
'afterSales.type.WrongItem': 'Wrong item received',
|
||||
'afterSales.type.Other': 'Other',
|
||||
'afterSales.note': 'Description',
|
||||
'afterSales.notePlaceholder': 'Tell us what is wrong…',
|
||||
'afterSales.photos': 'Photos',
|
||||
'afterSales.photosHint':
|
||||
'Up to 5 photos. On a phone you can take a picture with the camera.',
|
||||
'afterSales.uploading': 'Uploading…',
|
||||
'afterSales.maxPhotos': 'Maximum 5 photos.',
|
||||
'afterSales.uploadError': 'Could not upload photo.',
|
||||
'afterSales.submit': 'Submit request',
|
||||
'afterSales.submitting': 'Sending…',
|
||||
'afterSales.submitError': 'Could not submit request.',
|
||||
'afterSales.successTitle': 'Request received',
|
||||
'afterSales.successBody':
|
||||
'We will review it and get back to you by email.',
|
||||
'afterSales.missingOrder': 'Open this page from your order email or account.',
|
||||
'afterSales.loadError': 'Could not load this order for after-sales.',
|
||||
'account.loginPrompt': ' to view your account.',
|
||||
'favorites.title': 'Favorite products',
|
||||
'favorites.empty': 'No favorite products yet.',
|
||||
|
||||
@@ -15,6 +15,9 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'meta.checkout.description': 'Dokończ zamówienie.',
|
||||
'meta.order.title': 'Potwierdzenie zamówienia — iLED',
|
||||
'meta.order.description': 'Dziękujemy za zamówienie.',
|
||||
'meta.afterSales.title': 'Zwrot / reklamacja — iLED',
|
||||
'meta.afterSales.description':
|
||||
'Zgłoś zwrot lub problem z zamówieniem.',
|
||||
'meta.login.title': 'Logowanie — iLED',
|
||||
'meta.login.description': 'Zaloguj się lub załóż konto.',
|
||||
'meta.favorites.title': 'Ulubione — iLED',
|
||||
@@ -356,6 +359,37 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'account.logout': 'Wyloguj',
|
||||
'account.myOrders': 'Moje zamówienia',
|
||||
'account.details': 'Szczegóły',
|
||||
'account.afterSales': 'Zwrot / reklamacja',
|
||||
'checkout.afterSales': 'Zwrot / reklamacja',
|
||||
'afterSales.title': 'Zwrot lub zgłoszenie problemu',
|
||||
'afterSales.subtitle':
|
||||
'Wybierz produkty z zamówienia, opisz problem i opcjonalnie dodaj zdjęcia.',
|
||||
'afterSales.selectItems': 'Wybierz produkty',
|
||||
'afterSales.remaining': 'Pozostało do zgłoszenia',
|
||||
'afterSales.qty': 'Ilość',
|
||||
'afterSales.requestType': 'Czego dotyczy zgłoszenie?',
|
||||
'afterSales.type.Withdrawal': 'Zwrot / odstąpienie',
|
||||
'afterSales.type.Defect': 'Wada / uszkodzenie',
|
||||
'afterSales.type.MissingPart': 'Brakująca część',
|
||||
'afterSales.type.WrongItem': 'Otrzymano zły produkt',
|
||||
'afterSales.type.Other': 'Inne',
|
||||
'afterSales.note': 'Opis',
|
||||
'afterSales.notePlaceholder': 'Opisz, co jest nie tak…',
|
||||
'afterSales.photos': 'Zdjęcia',
|
||||
'afterSales.photosHint':
|
||||
'Do 5 zdjęć. Na telefonie możesz zrobić zdjęcie aparatem.',
|
||||
'afterSales.uploading': 'Przesyłanie…',
|
||||
'afterSales.maxPhotos': 'Maksymalnie 5 zdjęć.',
|
||||
'afterSales.uploadError': 'Nie udało się przesłać zdjęcia.',
|
||||
'afterSales.submit': 'Wyślij zgłoszenie',
|
||||
'afterSales.submitting': 'Wysyłanie…',
|
||||
'afterSales.submitError': 'Nie udało się wysłać zgłoszenia.',
|
||||
'afterSales.successTitle': 'Zgłoszenie przyjęte',
|
||||
'afterSales.successBody':
|
||||
'Przejrzymy je i odezwiemy się e-mailem.',
|
||||
'afterSales.missingOrder':
|
||||
'Otwórz tę stronę z e-maila zamówienia lub konta.',
|
||||
'afterSales.loadError': 'Nie udało się wczytać zamówienia.',
|
||||
'account.noOrders': 'Brak zamówień.',
|
||||
'account.loginPrompt': ', aby zobaczyć konto.',
|
||||
'favorites.title': 'Ulubione produkty',
|
||||
|
||||
@@ -176,6 +176,72 @@ export class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
export type AfterSalesRequestType =
|
||||
| 'withdrawal'
|
||||
| 'defect'
|
||||
| 'missingPart'
|
||||
| 'wrongItem'
|
||||
| 'other';
|
||||
|
||||
export interface AfterSalesOrderLine {
|
||||
orderLineId: string;
|
||||
productName: string;
|
||||
variantColor: string;
|
||||
sku: string;
|
||||
imageUrl: string | null;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
remainingClaimable: number;
|
||||
}
|
||||
|
||||
export interface AfterSalesOrder {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
lines: AfterSalesOrderLine[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AfterSalesService {
|
||||
readonly #api = inject(ApiService);
|
||||
|
||||
getOrder(orderId: string, token?: string | null) {
|
||||
const params: Record<string, string> = {};
|
||||
if (token) {
|
||||
params['token'] = token;
|
||||
}
|
||||
return this.#api.get<AfterSalesOrder>(
|
||||
`/after-sales/orders/${orderId}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
upload(orderId: string, file: File, token?: string | null) {
|
||||
const form = new FormData();
|
||||
form.append('orderId', orderId);
|
||||
if (token) {
|
||||
form.append('token', token);
|
||||
}
|
||||
form.append('file', file);
|
||||
return this.#api.uploadForm<{ url: string }>(
|
||||
'/after-sales/uploads',
|
||||
form,
|
||||
);
|
||||
}
|
||||
|
||||
create(body: {
|
||||
orderId: string;
|
||||
token?: string | null;
|
||||
type: AfterSalesRequestType;
|
||||
customerNote: string;
|
||||
lines: { orderLineId: string; quantity: number }[];
|
||||
attachmentUrls: string[];
|
||||
}) {
|
||||
return this.#api.post<{ id: string }>('/after-sales/requests', body);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FavoritesService {
|
||||
readonly #api = inject(ApiService);
|
||||
|
||||
@@ -56,11 +56,18 @@
|
||||
|
||||
<span class="font-bold">{{ order.total | i18nCurrency }}</span>
|
||||
|
||||
<a routerLink="/order/{{ order.id }}" class="text-iled-primary hover:underline">
|
||||
|
||||
{{ 'account.details' | translate }}
|
||||
|
||||
</a>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a routerLink="/order/{{ order.id }}" class="text-iled-primary hover:underline">
|
||||
{{ 'account.details' | translate }}
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/after-sales']"
|
||||
[queryParams]="{ orderId: order.id }"
|
||||
class="text-iled-primary hover:underline"
|
||||
>
|
||||
{{ 'account.afterSales' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<app-shell>
|
||||
<div class="mx-auto max-w-2xl px-6 py-12">
|
||||
<h1 class="mb-2 text-3xl font-bold">
|
||||
{{ 'afterSales.title' | translate }}
|
||||
</h1>
|
||||
<p class="mb-8 text-iled-grey-dark">
|
||||
{{ 'afterSales.subtitle' | translate }}
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
|
||||
} @else if (success()) {
|
||||
<div class="rounded border border-iled-grey-light bg-white p-6 text-center">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
{{ 'afterSales.successTitle' | translate }}
|
||||
</h2>
|
||||
<p class="mb-6 text-iled-grey-dark">
|
||||
{{ 'afterSales.successBody' | translate }}
|
||||
</p>
|
||||
<a routerLink="/" class="inline-flex bg-iled-black px-6 py-3 text-white">
|
||||
{{ 'checkout.backToShop' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
} @else if (order(); as o) {
|
||||
<form class="space-y-8" (ngSubmit)="handleSubmit($event)">
|
||||
<div>
|
||||
<p class="mb-1 text-sm text-iled-grey-dark">
|
||||
{{ 'checkout.orderNumber' | translate }}
|
||||
<strong>{{ o.orderNumber }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="mb-3 font-semibold">
|
||||
{{ 'afterSales.selectItems' | translate }}
|
||||
</legend>
|
||||
<div class="space-y-3">
|
||||
@for (line of o.lines; track line.orderLineId) {
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border border-iled-grey-light p-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="font-medium">{{ line.productName }}</div>
|
||||
<div class="text-sm text-iled-grey-dark">
|
||||
{{ line.sku }}
|
||||
@if (line.variantColor) {
|
||||
· {{ line.variantColor }}
|
||||
}
|
||||
· {{ line.unitPrice | i18nCurrency }}
|
||||
</div>
|
||||
<div class="text-xs text-iled-grey-dark">
|
||||
{{ 'afterSales.remaining' | translate }}:
|
||||
{{ line.remainingClaimable }} / {{ line.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<span>{{ 'afterSales.qty' | translate }}</span>
|
||||
<input
|
||||
type="number"
|
||||
class="w-20 border border-iled-grey-light px-2 py-1"
|
||||
min="0"
|
||||
[max]="line.remainingClaimable"
|
||||
[value]="qty(line)"
|
||||
[disabled]="line.remainingClaimable === 0"
|
||||
(change)="setQty(line, $any($event.target).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend class="mb-3 font-semibold">
|
||||
{{ 'afterSales.requestType' | translate }}
|
||||
</legend>
|
||||
<div class="space-y-2 text-left">
|
||||
@for (t of types; track t) {
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="afterSalesType"
|
||||
[value]="t"
|
||||
[(ngModel)]="type"
|
||||
/>
|
||||
{{ typeLabel(t) | translate }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-left">
|
||||
<label class="mb-2 block font-semibold" for="as-note">
|
||||
{{ 'afterSales.note' | translate }}
|
||||
</label>
|
||||
<textarea
|
||||
id="as-note"
|
||||
name="note"
|
||||
rows="4"
|
||||
class="w-full border border-iled-grey-light px-3 py-2"
|
||||
[(ngModel)]="note"
|
||||
[placeholder]="'afterSales.notePlaceholder' | translate"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-left">
|
||||
<label class="mb-2 block font-semibold">
|
||||
{{ 'afterSales.photos' | translate }}
|
||||
</label>
|
||||
<p class="mb-3 text-sm text-iled-grey-dark">
|
||||
{{ 'afterSales.photosHint' | translate }}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
multiple
|
||||
class="mb-3 block w-full text-sm"
|
||||
[disabled]="uploading() || attachmentUrls().length >= 5"
|
||||
(change)="handleFiles($event)"
|
||||
/>
|
||||
@if (uploading()) {
|
||||
<p class="mb-2 text-sm text-iled-grey-dark">
|
||||
{{ 'afterSales.uploading' | translate }}
|
||||
</p>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (url of attachmentUrls(); track url) {
|
||||
<div class="relative">
|
||||
<img
|
||||
[src]="photoSrc(url)"
|
||||
alt=""
|
||||
class="h-20 w-20 object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-0 bg-black/70 px-1 text-xs text-white"
|
||||
(click)="removePhoto(url)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="text-sm text-red-700">{{ error() }}</p>
|
||||
}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-iled-primary px-6 py-3 font-semibold text-iled-black disabled:opacity-50"
|
||||
[disabled]="submitting() || uploading()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
{{ 'afterSales.submitting' | translate }}
|
||||
} @else {
|
||||
{{ 'afterSales.submit' | translate }}
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
} @else if (error()) {
|
||||
<p class="text-red-700">{{ error() }}</p>
|
||||
<a routerLink="/" class="mt-4 inline-block text-iled-primary underline">
|
||||
{{ 'checkout.backToShop' | translate }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</app-shell>
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import { I18nService } from '../../core/i18n/i18n.service';
|
||||
import { ShellComponent } from '../../shared/layout/layout.components';
|
||||
import { API_BASE_URL } from '../../core/constants';
|
||||
import {
|
||||
AfterSalesOrder,
|
||||
AfterSalesOrderLine,
|
||||
AfterSalesRequestType,
|
||||
AfterSalesService,
|
||||
} from '../../core/shop.services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-after-sales',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ShellComponent,
|
||||
FormsModule,
|
||||
TranslatePipe,
|
||||
I18nCurrencyPipe,
|
||||
RouterLink,
|
||||
],
|
||||
templateUrl: './after-sales.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AfterSalesComponent implements OnInit {
|
||||
readonly #api = inject(AfterSalesService);
|
||||
readonly #route = inject(ActivatedRoute);
|
||||
readonly #i18n = inject(I18nService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly submitting = signal(false);
|
||||
readonly uploading = signal(false);
|
||||
readonly error = signal('');
|
||||
readonly success = signal(false);
|
||||
readonly order = signal<AfterSalesOrder | null>(null);
|
||||
|
||||
orderId = '';
|
||||
token = '';
|
||||
type: AfterSalesRequestType = 'withdrawal';
|
||||
note = '';
|
||||
readonly selectedQty = new Map<string, number>();
|
||||
readonly attachmentUrls = signal<string[]>([]);
|
||||
|
||||
readonly types: AfterSalesRequestType[] = [
|
||||
'withdrawal',
|
||||
'defect',
|
||||
'missingPart',
|
||||
'wrongItem',
|
||||
'other',
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#route.queryParamMap.subscribe((params) => {
|
||||
this.orderId = params.get('orderId') ?? '';
|
||||
this.token = params.get('token') ?? '';
|
||||
if (this.orderId) {
|
||||
this.#load();
|
||||
} else {
|
||||
this.loading.set(false);
|
||||
this.error.set(this.#i18n.t('afterSales.missingOrder'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set('');
|
||||
this.#api.getOrder(this.orderId, this.token || null).subscribe({
|
||||
next: (o) => {
|
||||
this.order.set(o);
|
||||
this.selectedQty.clear();
|
||||
for (const line of o.lines) {
|
||||
if (line.remainingClaimable > 0) {
|
||||
this.selectedQty.set(line.orderLineId, 0);
|
||||
}
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.error.set(this.#i18n.t('afterSales.loadError'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
qty(line: AfterSalesOrderLine): number {
|
||||
return this.selectedQty.get(line.orderLineId) ?? 0;
|
||||
}
|
||||
|
||||
setQty(line: AfterSalesOrderLine, raw: string | number): void {
|
||||
const n = Math.max(
|
||||
0,
|
||||
Math.min(line.remainingClaimable, Math.floor(Number(raw) || 0)),
|
||||
);
|
||||
this.selectedQty.set(line.orderLineId, n);
|
||||
}
|
||||
|
||||
typeLabel(type: AfterSalesRequestType): string {
|
||||
const key = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
return `afterSales.type.${key}`;
|
||||
}
|
||||
|
||||
photoSrc(url: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
return `${API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
handleFiles(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
input.value = '';
|
||||
if (!files?.length || this.uploading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 5 - this.attachmentUrls().length;
|
||||
if (remaining <= 0) {
|
||||
this.error.set(this.#i18n.t('afterSales.maxPhotos'));
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = Array.from(files).slice(0, remaining);
|
||||
this.uploading.set(true);
|
||||
this.error.set('');
|
||||
let pending = batch.length;
|
||||
|
||||
for (const file of batch) {
|
||||
this.#api.upload(this.orderId, file, this.token || null).subscribe({
|
||||
next: (res) => {
|
||||
this.attachmentUrls.update((urls) => [...urls, res.url]);
|
||||
pending -= 1;
|
||||
if (pending <= 0) {
|
||||
this.uploading.set(false);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
pending -= 1;
|
||||
this.error.set(this.#i18n.t('afterSales.uploadError'));
|
||||
if (pending <= 0) {
|
||||
this.uploading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removePhoto(url: string): void {
|
||||
this.attachmentUrls.update((urls) => urls.filter((u) => u !== url));
|
||||
}
|
||||
|
||||
handleSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
if (this.submitting() || !this.order()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [...this.selectedQty.entries()]
|
||||
.filter(([, q]) => q > 0)
|
||||
.map(([orderLineId, quantity]) => ({ orderLineId, quantity }));
|
||||
|
||||
if (lines.length === 0) {
|
||||
this.error.set(this.#i18n.t('afterSales.selectItems'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
this.error.set('');
|
||||
this.#api
|
||||
.create({
|
||||
orderId: this.orderId,
|
||||
token: this.token || null,
|
||||
type: this.type,
|
||||
customerNote: this.note.trim(),
|
||||
lines,
|
||||
attachmentUrls: this.attachmentUrls(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.submitting.set(false);
|
||||
this.success.set(true);
|
||||
},
|
||||
error: (err) => {
|
||||
this.submitting.set(false);
|
||||
const msg =
|
||||
err?.error?.message ||
|
||||
err?.error?.title ||
|
||||
this.#i18n.t('afterSales.submitError');
|
||||
this.error.set(typeof msg === 'string' ? msg : this.#i18n.t('afterSales.submitError'));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -353,6 +353,7 @@ export class OrderConfirmationComponent implements OnInit {
|
||||
readonly #route = inject(ActivatedRoute);
|
||||
readonly #router = inject(Router);
|
||||
readonly #i18n = inject(I18nService);
|
||||
readonly #auth = inject(AuthService);
|
||||
|
||||
readonly order = signal<OrderDto | null>(null);
|
||||
readonly syncing = signal(false);
|
||||
@@ -369,9 +370,23 @@ export class OrderConfirmationComponent implements OnInit {
|
||||
}
|
||||
|
||||
needsPayment(order: OrderDto): boolean {
|
||||
const method = enumKey(order.paymentMethod);
|
||||
const status = enumKey(order.status);
|
||||
return method === 'PayU' && status === 'PendingPayment';
|
||||
}
|
||||
|
||||
canAfterSales(order: OrderDto): boolean {
|
||||
if (!this.#auth.isLoggedIn()) {
|
||||
// Guests use the emailed magic link (token required).
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = enumKey(order.status);
|
||||
return (
|
||||
order.paymentMethod === 'PayU' &&
|
||||
order.status === 'PendingPayment'
|
||||
status === 'Paid' ||
|
||||
status === 'Processing' ||
|
||||
status === 'Shipped' ||
|
||||
status === 'Delivered'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -417,7 +432,10 @@ export class OrderConfirmationComponent implements OnInit {
|
||||
const order = await firstValueFrom(this.#orders.getOrder(orderId));
|
||||
this.order.set(order);
|
||||
|
||||
if (order.status === 'Paid' || order.paymentMethod !== 'PayU') {
|
||||
if (
|
||||
enumKey(order.status) === 'Paid' ||
|
||||
enumKey(order.paymentMethod) !== 'PayU'
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,14 @@
|
||||
{{ 'checkout.payAgain' | translate }}
|
||||
}
|
||||
</button>
|
||||
} @else if (canAfterSales(o)) {
|
||||
<a
|
||||
[routerLink]="['/after-sales']"
|
||||
[queryParams]="{ orderId: o.id }"
|
||||
class="inline-flex bg-iled-primary px-8 py-3 font-semibold text-iled-black"
|
||||
>
|
||||
{{ 'checkout.afterSales' | translate }}
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
routerLink="/"
|
||||
|
||||
Reference in new issue
Block a user