fix(discount): correct edit-form data + calendar clipping + input styling

- List endpoint returned camelCase keys (getArrayResult) so the edit modal
  read empty discount_type/target_*; return toArray() (snake_case) instead.
- Portal the Persian date-picker calendar to body (fixed) so it no longer
  renders under the modal's overflow-scroll body.
- Style the PriceInput/number fields with cp-input in the rule modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 12:37:43 +03:30
co-authored by Claude Fable 5
parent 809ad11680
commit 204c7debe1
4 changed files with 35 additions and 17 deletions
+3 -3
View File
@@ -239,8 +239,8 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
<div> <div>
<label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label> <label style={labelStyle()}>{f.discount_type === 'percent' ? 'درصد تخفیف (۰ تا ۱۰۰)' : 'مبلغ تخفیف (تومان)'}</label>
{f.discount_type === 'percent' {f.discount_type === 'percent'
? <input className="cp-input" type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} /> ? <input className="cp-input" style={{ width: '100%' }} type="number" inputMode="numeric" min={0} max={100} value={f.value} onChange={(e) => set('value', Number(e.target.value) || 0)} />
: <PriceInput value={f.value} onChange={(v) => set('value', v)} />} : <PriceInput className="cp-input" style={{ width: '100%' }} value={f.value} onChange={(v) => set('value', v)} />}
</div> </div>
<div> <div>
<label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label> <label style={labelStyle()}>اولویت (بزرگتر = مهمتر)</label>
@@ -262,7 +262,7 @@ function RuleModal({ initial, onClose, onSaved }: { initial: DiscountRule | null
{f.type === 'invoice_amount' && ( {f.type === 'invoice_amount' && (
<div> <div>
<label style={labelStyle()}>حداقل مبلغ فاکتور (تومان)</label> <label style={labelStyle()}>حداقل مبلغ فاکتور (تومان)</label>
<PriceInput value={f.min_amount_toman} onChange={(v) => set('min_amount_toman', v)} /> <PriceInput className="cp-input" style={{ width: '100%' }} value={f.min_amount_toman} onChange={(v) => set('min_amount_toman', v)} />
</div> </div>
)} )}
{f.type === 'specific_patient' && ( {f.type === 'specific_patient' && (
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { formatDate } from '../../lib/utils'; import { formatDate } from '../../lib/utils';
import PersianCalendar from './PersianCalendar'; import PersianCalendar from './PersianCalendar';
@@ -15,11 +16,20 @@ interface Props {
export default function PersianDatePicker({ value, onChange, placeholder = 'انتخاب تاریخ', height = 38, minWidth = 148, enableYearPicker = false }: Props) { export default function PersianDatePicker({ value, onChange, placeholder = 'انتخاب تاریخ', height = 38, minWidth = 148, enableYearPicker = false }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; right: number } | null>(null);
// تقویم را به body پورت می‌کنیم تا داخل مودال‌های overflow-scroll زیر کادر نرود.
const toggle = () => {
const r = triggerRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom, right: window.innerWidth - r.right });
setOpen((o) => !o);
};
return ( return (
<div style={{ position: 'relative', display: 'inline-block' }}> <div ref={triggerRef} style={{ position: 'relative', display: 'inline-block' }}>
<div <div
onClick={() => setOpen((o) => !o)} onClick={toggle}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 7, display: 'flex', alignItems: 'center', gap: 7,
height, padding: '0 10px', borderRadius: 'var(--r-sm)', height, padding: '0 10px', borderRadius: 'var(--r-sm)',
@@ -40,13 +50,16 @@ export default function PersianDatePicker({ value, onChange, placeholder = 'ان
)} )}
</div> </div>
{open && ( {open && pos && createPortal(
<PersianCalendar <div style={{ position: 'fixed', top: pos.top, right: pos.right, zIndex: 3000 }}>
value={value} <PersianCalendar
onChange={(v) => { onChange(v); setOpen(false); }} value={value}
onClose={() => setOpen(false)} onChange={(v) => { onChange(v); setOpen(false); }}
enableYearPicker={enableYearPicker} onClose={() => setOpen(false)}
/> enableYearPicker={enableYearPicker}
/>
</div>,
document.body,
)} )}
</div> </div>
); );
@@ -49,7 +49,8 @@ class DiscountController extends BaseController
if ($ownerId === null) { if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
} }
return $this->success(['data' => $this->ruleRepo->listForOwner($ownerType, $ownerId)]); $items = array_map(fn(DiscountRule $r) => $r->toArray(), $this->ruleRepo->findAllForOwner($ownerType, $ownerId));
return $this->success(['data' => $items]);
} }
#[Route('/api/v1/admin/discount-rules', methods: ['POST'])] #[Route('/api/v1/admin/discount-rules', methods: ['POST'])]
@@ -37,13 +37,17 @@ class DiscountRuleRepository extends ServiceEntityRepository
return $this->findBy(['ownerType' => $ownerType, 'ownerId' => $ownerId, 'active' => true], ['priority' => 'DESC']); return $this->findBy(['ownerType' => $ownerType, 'ownerId' => $ownerId, 'active' => true], ['priority' => 'DESC']);
} }
/** لیست ادمین به‌صورت آرایه (array hydration). */ /**
public function listForOwner(string $ownerType, int $ownerId): array * لیست کامل قوانین یک owner (شامل غیرفعال‌ها) برای ادمین، مرتب بر priority.
* موجودیت‌ها برمی‌گردند تا کنترلر با toArray() قرارداد snake_case فرانت را بدهد.
* @return DiscountRule[]
*/
public function findAllForOwner(string $ownerType, int $ownerId): array
{ {
return $this->createQueryBuilder('r') return $this->createQueryBuilder('r')
->where('r.ownerType = :t')->setParameter('t', $ownerType) ->where('r.ownerType = :t')->setParameter('t', $ownerType)
->andWhere('r.ownerId = :i')->setParameter('i', $ownerId) ->andWhere('r.ownerId = :i')->setParameter('i', $ownerId)
->orderBy('r.priority', 'DESC')->addOrderBy('r.id', 'DESC') ->orderBy('r.priority', 'DESC')->addOrderBy('r.id', 'DESC')
->getQuery()->getArrayResult(); ->getQuery()->getResult();
} }
} }