Files
clinicpro/assets/admin/pages/LogsPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30

284 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { TrashIcon, ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { AppLog } from '../types';
import { formatDateTime } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import Pagination from '../components/ui/Pagination';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
const LEVEL_META: Record<string, { label: string; cls: string }> = {
emergency: { label: 'اضطراری', cls: 'red' },
alert: { label: 'هشدار جدی', cls: 'red' },
critical: { label: 'بحرانی', cls: 'red' },
error: { label: 'خطا', cls: 'red' },
warning: { label: 'اخطار', cls: 'amber' },
notice: { label: 'اطلاع', cls: 'gray' },
info: { label: 'اطلاعات', cls: 'gray' },
debug: { label: 'دیباگ', cls: 'gray' },
};
const LEVEL_FILTER_OPTIONS = [
{ value: '', label: 'همه سطوح' },
{ value: 'error', label: 'خطا' },
{ value: 'warning', label: 'اخطار' },
{ value: 'critical', label: 'بحرانی' },
];
type Tab = 'logs' | 'settings';
export default function LogsPage() {
const [tab, setTab] = useState<Tab>('logs');
const [page, setPage] = useState(1);
const [level, setLevel] = useState('');
const [search, setSearch] = useState('');
const [viewLog, setViewLog] = useState<AppLog | null>(null);
const [confirmClear, setConfirmClear] = useState(false);
const [downloading, setDownloading] = useState(false);
const limit = 25;
const queryClient = useQueryClient();
const handleDownload = async () => {
setDownloading(true);
try {
const qs =
(level ? `&level=${level}` : '') +
(search ? `&search=${encodeURIComponent(search)}` : '');
await api.download(`/api/v1/admin/logs/export?${qs}`);
} finally {
setDownloading(false);
}
};
const logsQuery = useQuery({
queryKey: ['admin-logs', page, level, search],
queryFn: () =>
api.get<PaginatedResponse<AppLog>>(
`/api/v1/admin/logs?page=${page}&limit=${limit}` +
(level ? `&level=${level}` : '') +
(search ? `&search=${encodeURIComponent(search)}` : ''),
),
});
const clearMutation = useMutation({
mutationFn: () => api.delete<ApiResponse<{ deleted: number }>>('/api/v1/admin/logs'),
onSuccess: () => {
setConfirmClear(false);
setPage(1);
queryClient.invalidateQueries({ queryKey: ['admin-logs'] });
},
});
const columns: Column<AppLog>[] = [
{ key: 'created_at', header: 'زمان', render: (l) => formatDateTime(l.created_at) },
{
key: 'level',
header: 'سطح',
render: (l) => {
const meta = LEVEL_META[l.level] ?? { label: l.level, cls: 'gray' };
return <span className={`badge ${meta.cls}`}><span className="bdot" />{meta.label}</span>;
},
},
{
key: 'message',
header: 'پیام',
render: (l) => (
<button
type="button"
title="نمایش جزئیات"
onClick={() => setViewLog(l)}
style={{ fontSize: 12.5, display: 'block', maxWidth: 420, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'right', background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--primary)', width: '100%' }}
>
{l.message}
</button>
),
},
{ key: 'path', header: 'مسیر', render: (l) => <span dir="ltr" className="muted" style={{ fontSize: 12 }}>{l.path ?? '—'}</span> },
];
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">لاگ‌ها</h1>
<div className="muted">رویدادهای ثبت‌شده‌ی سیستم (اخطار و بالاتر)</div>
</div>
{tab === 'logs' && (
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn ghost sm"
onClick={handleDownload}
disabled={downloading || (logsQuery.data?.meta?.totalRecords ?? 0) === 0}
>
<ArrowDownTrayIcon style={{ width: 16, height: 16 }} />
{downloading ? 'در حال دانلود...' : 'دانلود همه لاگ‌ها'}
</button>
<button
className="btn danger sm"
onClick={() => setConfirmClear(true)}
disabled={(logsQuery.data?.meta?.totalRecords ?? 0) === 0}
>
<TrashIcon style={{ width: 16, height: 16 }} />
حذف همه لاگ‌ها
</button>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 'var(--gap)' }}>
<button
className={`btn ${tab === 'logs' ? 'primary' : 'ghost'} sm`}
onClick={() => setTab('logs')}
>
فهرست لاگ‌ها
</button>
<button
className={`btn ${tab === 'settings' ? 'primary' : 'ghost'} sm`}
onClick={() => setTab('settings')}
>
مدت نگهداری
</button>
</div>
{tab === 'logs' && (
<div className="card">
<div className="card-pad">
<div style={{ marginBottom: 12, display: 'flex', gap: 12, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<input
className="input"
style={{ maxWidth: 260 }}
placeholder="جستجو در متن..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
<div style={{ width: 200 }}>
<SearchableSelect
options={LEVEL_FILTER_OPTIONS}
value={level}
onChange={(v) => { setLevel(v ? String(v) : ''); setPage(1); }}
placeholder="همه سطوح"
/>
</div>
</div>
<DataTable<AppLog>
columns={columns}
data={logsQuery.data?.data ?? []}
loading={logsQuery.isLoading}
emptyMessage="لاگی یافت نشد"
/>
<Pagination
page={page}
total={logsQuery.data?.meta?.totalRecords ?? 0}
limit={limit}
onPageChange={setPage}
/>
</div>
</div>
)}
{tab === 'settings' && <RetentionSettings />}
<Modal open={!!viewLog} title="جزئیات لاگ" onClose={() => setViewLog(null)}>
{viewLog && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 360, maxWidth: 640 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5 }}>
<div><span className="muted">سطح: </span>{LEVEL_META[viewLog.level]?.label ?? viewLog.level}</div>
<div><span className="muted">زمان: </span>{formatDateTime(viewLog.created_at)}</div>
{viewLog.path && <div><span className="muted">مسیر: </span><span dir="ltr">{viewLog.path}</span></div>}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: 1.9, fontSize: 13.5, padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-3)', border: '1px solid var(--border)' }} dir="ltr">
{viewLog.message}
</div>
{viewLog.context && (
<div>
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>context</div>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--surface)', border: '1px solid var(--border)', margin: 0 }} dir="ltr">
{viewLog.context}
</pre>
</div>
)}
</div>
)}
</Modal>
<ConfirmDialog
open={confirmClear}
title="حذف همه لاگ‌ها"
message="همه‌ی لاگ‌های سیستم برای همیشه حذف می‌شوند. این عمل قابل بازگشت نیست. ادامه می‌دهید؟"
confirmLabel="حذف همه"
danger
loading={clearMutation.isPending}
onConfirm={() => clearMutation.mutate()}
onCancel={() => setConfirmClear(false)}
/>
</div>
);
}
function RetentionSettings() {
const queryClient = useQueryClient();
const [days, setDays] = useState<string>('');
const [touched, setTouched] = useState(false);
const settingsQuery = useQuery({
queryKey: ['admin-settings'],
queryFn: () => api.get<ApiResponse<Record<string, string | boolean>>>('/api/v1/admin/settings'),
});
const current = String(settingsQuery.data?.data?.log_retention_days ?? '');
const value = touched ? days : current;
const saveMutation = useMutation({
mutationFn: (retentionDays: string) =>
api.patch<ApiResponse<Record<string, string | boolean>>>('/api/v1/admin/settings', {
log_retention_days: retentionDays,
}),
onSuccess: () => {
setTouched(false);
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
});
return (
<div className="card">
<div className="card-pad">
<div style={{ maxWidth: 420, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label className="muted" style={{ fontSize: 13, display: 'block', marginBottom: 6 }}>
مدت نگهداری لاگ‌ها (روز)
</label>
<input
className="input"
type="text"
inputMode="numeric"
dir="ltr"
value={value}
placeholder={settingsQuery.isLoading ? 'در حال بارگذاری...' : '90'}
onChange={(e) => { setDays(digitsOnly(e.target.value)); setTouched(true); }}
style={{ maxWidth: 200 }}
/>
<div className="muted" style={{ fontSize: 12, marginTop: 6, lineHeight: 1.7 }}>
لاگ‌های قدیمی‌تر از این مدت به‌صورت روزانه به‌صورت خودکار حذف می‌شوند. مقدار ۰ یعنی نگهداری نامحدود.
</div>
</div>
<div>
<button
className="btn primary sm"
disabled={saveMutation.isPending || !touched || value === current}
onClick={() => saveMutation.mutate(String(parseInt(value || '0', 10)))}
>
{saveMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</div>
</div>
);
}