feat(discount): apply rule-based discounts on the session payment step

PaymentStep now loads /session/{uuid}/discount-suggestions and lets the
operator apply a suggested rule (PATCH discount_rule_uuid) or remove it,
alongside the existing manual discount. Adds an amount summary (before /
discount + source rule label / final payable). Verified end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 12:04:56 +03:30
co-authored by Claude Fable 5
parent 18d9cc9812
commit 2691751e70
2 changed files with 57 additions and 6 deletions
@@ -27,6 +27,7 @@ export interface SessionCardData {
discount_type?: 'percent' | 'fixed' | null;
discount_value?: number;
discount_rials?: number;
applied_discount_rule_label?: string | null;
paid_total_rials?: number;
paid_at?: number | null;
payments?: SessionPaymentEntry[];
@@ -1,9 +1,11 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatRial } from '../../lib/utils';
import type { DiscountSuggestion } from '../../types';
import type { SessionCardData } from '../SessionServiceCard';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
@@ -54,6 +56,7 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
qc.invalidateQueries({ queryKey: ['patient-wallet', recordUuid] });
qc.invalidateQueries({ queryKey: ['discount-suggestions', sessionUuid] });
};
const discountMut = useMutation({
@@ -68,13 +71,22 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
onError: (e: Error) => toast.error(e.message),
});
const suggestionsQ = useQuery({
queryKey: ['discount-suggestions', sessionUuid],
queryFn: () => api.get<ApiResponse<DiscountSuggestion[]>>(`/api/v1/session/${sessionUuid}/discount-suggestions`),
});
const suggestions: DiscountSuggestion[] = (suggestionsQ.data?.data as any)?.data ?? (suggestionsQ.data?.data as any) ?? [];
const applyDiscount = () => {
if (!discountType || !discountValue) return;
discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) });
};
const applyRule = (ruleUuid: string) => {
discountMut.mutate({ discount_rule_uuid: ruleUuid });
};
const removeDiscount = () => {
setDiscountType(''); setDiscountValue('');
discountMut.mutate({ discount_type: null });
discountMut.mutate({ discount_rule_uuid: null });
};
const submitPayment = (method: string) => {
if (!amount) return;
@@ -85,6 +97,8 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
const discountRials = session.discount_rials ?? 0;
const debt = session.patient_debt_rials ?? 0;
const payments = session.payments ?? [];
const appliedRuleLabel = session.applied_discount_rule_label ?? null;
const payable = Math.max(0, finalPrice - discountRials);
return (
<>
@@ -95,6 +109,31 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#525252' }}>{formatRial(finalPrice)}</span>
</div>
{/* تخفیف‌های پیشنهادی (قوانین تخفیف) */}
{suggestions.length > 0 && (
<div className="dark:border-[#35343D]" style={{ border: '1px solid #E8EBFF', background: '#F7F8FF', borderRadius: 8, padding: 12, marginBottom: 16 }}>
<span style={{ ...fieldLabel, marginBottom: 10, fontWeight: 600, color: '#3B3F9F' }}>تخفیفهای قابل اعمال:</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{suggestions.map((s) => (
<div key={s.rule_uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#374151' }}>
{s.rule_name} <span style={{ color: '#D32F2F' }}>{formatRial(s.discount_rials)}</span>
{' '}<span style={{ color: '#6B7280', fontSize: 12 }}>({s.discount_type === 'percent' ? `${s.value}٪` : 'مبلغ ثابت'})</span>
</span>
<button
type="button"
onClick={() => applyRule(s.rule_uuid)}
disabled={discountMut.isPending}
style={{ height: 32, minWidth: 64, border: 'none', borderRadius: 6, background: '#5559CE', color: '#fff', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' }}
>
اعمال
</button>
</div>
))}
</div>
</div>
)}
{/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */}
<span style={fieldLabel}>تخفیف:</span>
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
@@ -134,10 +173,21 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
</div>
</div>
<p style={{ fontSize: 13, marginBottom: 16 }}>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ تخفیف:</span>{' '}
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
</p>
{/* خلاصه‌ی مبلغ: قبل از تخفیف / تخفیف / نهایی / منبع */}
<div className="dark:border-[#35343D]" style={{ border: '1px solid #e0e0e0', borderRadius: 8, padding: 12, marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>مبلغ قبل از تخفیف:</span>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>{formatRial(finalPrice)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span className="dark:text-[#A1A1A1]" style={{ color: '#6B7280' }}>مبلغ تخفیف{appliedRuleLabel ? ` (${appliedRuleLabel})` : ''}:</span>
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 700, borderTop: '1px dashed #e0e0e0', paddingTop: 6 }}>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ نهایی قابل پرداخت:</span>
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>{formatRial(payable)}</span>
</div>
</div>
{/* تاریخ پرداخت */}
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b' }}>تاریخ پرداخت</span>