feat: doctor reply + diagnosis + tracking code per health request
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · drsousan (push) Successful in 28s

Backend:
- HealthRequest model: TrackingCode (DR-XXXXXX), Diagnosis,
  DoctorReply, RepliedAt fields
- Runtime migration: ALTER TABLE adds 4 new columns to existing DB
- POST /api/health-request: auto-generates tracking code, returns it
- PUT /api/health-requests/{id}/reply: doctor sets diagnosis + reply
- GET /api/health-request/track/{code}: public lookup by tracking code
- GET /api/health-requests?phone=: filter history by phone number

Admin panel:
- Request table shows tracking code column (gold badge)
- Detail modal (680px): tracking code header, patient info, full message
- Previous doctor reply shown in green box if exists
- Reply form: diagnosis input + textarea for doctor message
- History panel: all requests from same phone, click to switch
- 'پاسخ / مشاهده' button opens reply modal directly

Frontend:
- After form submit: shows tracking code in green box to user
  (format: DR-XXXXXX, stays visible 8 seconds)
- Box auto-hides and form resets after timeout

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 22:03:00 +03:30
parent 1e51df406b
commit 22d0ecb330
4 changed files with 187 additions and 47 deletions
+6
View File
@@ -171,17 +171,23 @@ public class PatientVisit
public class HealthRequest
{
public int Id { get; set; }
[MaxLength(20)] public string TrackingCode { get; set; } = ""; // e.g. DR-A3F7K2
[MaxLength(150)] public string FullName { get; set; } = "";
[MaxLength(20)] public string PhoneNumber { get; set; } = "";
[MaxLength(200)] public string Email { get; set; } = "";
public string Message { get; set; } = "";
[MaxLength(20)] public string Category { get; set; } = "beauty"; // beauty | health
public bool IsHandled { get; set; } = false;
// Doctor response
public string Diagnosis { get; set; } = ""; // پزشک: تشخیص
public string DoctorReply { get; set; } = ""; // پزشک: پاسخ/توضیح
public DateTime? RepliedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── DTOs ─────────────────────────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record DoctorReplyDto(string? Diagnosis, string? DoctorReply);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record SettingDto(string Key, string Value);
public record BulkSettingsDto(Dictionary<string, string> Settings);
+18 -2
View File
@@ -791,6 +791,7 @@
</div>
<button type="submit" class="form-submit" id="booking-submit">ارسال و رزرو نوبت</button>
</form>
<div id="booking-tracking" style="display:none"></div>
</div>
</div>
</div>
@@ -844,15 +845,30 @@
})
});
if (!res.ok) throw new Error();
btn.textContent = '✓ درخواست شما ثبت شد';
const data = await res.json();
const code = data.trackingCode || '';
btn.textContent = '✓ درخواست ثبت شد';
btn.style.background = '#2D7A4F';
// Show tracking code to user
const trackBox = document.getElementById('booking-tracking');
if (trackBox && code) {
trackBox.innerHTML = `
<div style="background:#E8F5E9;border-radius:14px;padding:1rem 1.4rem;border-right:4px solid #388E3C;margin-top:1rem">
<p style="font-size:.85rem;color:#2D7A4F;margin-bottom:.4rem;font-weight:600">✓ درخواست شما با موفقیت ثبت شد</p>
<p style="font-size:.82rem;color:#555;margin-bottom:.5rem">کد رهگیری شما برای پیگیری پاسخ پزشک:</p>
<div style="font-size:1.3rem;font-weight:700;letter-spacing:.1em;color:#1a1a1a;font-family:monospace;background:#fff;display:inline-block;padding:.3rem .8rem;border-radius:8px;border:1.5px solid #a5d6a7">${code}</div>
<p style="font-size:.75rem;color:#777;margin-top:.5rem">این کد را نزد خود نگه دارید. در اسرع وقت با شما تماس می‌گیریم.</p>
</div>`;
trackBox.style.display = 'block';
}
setTimeout(() => {
btn.textContent = 'ارسال و رزرو نوبت';
btn.style.background = '';
btn.disabled = false;
e.target.reset();
document.getElementById('booking-category').value = 'beauty';
}, 3500);
if (trackBox) { trackBox.innerHTML=''; trackBox.style.display='none'; }
}, 8000);
} catch {
btn.textContent = 'خطا — دوباره تلاش کنید';
btn.style.background = '#c62828';
+53 -2
View File
@@ -132,16 +132,31 @@ await using (var scope = app.Services.CreateAsyncScope())
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "HealthRequests" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"TrackingCode" TEXT NOT NULL DEFAULT '',
"FullName" TEXT NOT NULL DEFAULT '',
"PhoneNumber" TEXT NOT NULL DEFAULT '',
"Email" TEXT NOT NULL DEFAULT '',
"Message" TEXT NOT NULL DEFAULT '',
"Category" TEXT NOT NULL DEFAULT 'beauty',
"IsHandled" INTEGER NOT NULL DEFAULT 0,
"Diagnosis" TEXT NOT NULL DEFAULT '',
"DoctorReply" TEXT NOT NULL DEFAULT '',
"RepliedAt" TEXT,
"CreatedAt" TEXT NOT NULL DEFAULT ''
)
""");
} catch { }
// Add new columns to existing HealthRequests table (safe migration)
foreach (var col in new[] {
("TrackingCode", "TEXT NOT NULL DEFAULT ''"),
("Diagnosis", "TEXT NOT NULL DEFAULT ''"),
("DoctorReply", "TEXT NOT NULL DEFAULT ''"),
("RepliedAt", "TEXT") })
{
try { await db.Database.ExecuteSqlRawAsync(
$"ALTER TABLE HealthRequests ADD COLUMN \"{col.Item1}\" {col.Item2}"); }
catch { }
}
await SeedAsync(db);
}
@@ -705,18 +720,45 @@ app.MapPost("/api/health-request", async (HealthRequest req, AppDbContext db) =>
{
req.CreatedAt = DateTime.UtcNow;
req.IsHandled = false;
req.TrackingCode = "DR-" + GenerateTrackingCode();
db.HealthRequests.Add(req);
await db.SaveChangesAsync();
return Results.Ok(new { message = "درخواست شما ثبت شد" });
return Results.Ok(new { message = "درخواست شما ثبت شد", trackingCode = req.TrackingCode, id = req.Id });
});
app.MapGet("/api/health-requests", async (bool? handled, AppDbContext db) =>
// Public: look up own request by tracking code
app.MapGet("/api/health-request/track/{code}", async (string code, AppDbContext db) =>
{
var r = await db.HealthRequests.FirstOrDefaultAsync(x => x.TrackingCode == code);
if (r is null) return Results.NotFound(new { message = "کد رهگیری یافت نشد" });
return Results.Ok(new {
r.TrackingCode, r.FullName, r.Category, r.Message, r.IsHandled,
r.Diagnosis, r.DoctorReply, r.RepliedAt, r.CreatedAt
});
});
app.MapGet("/api/health-requests", async (bool? handled, string? phone, AppDbContext db) =>
{
var q = db.HealthRequests.AsQueryable();
if (handled.HasValue) q = q.Where(r => r.IsHandled == handled);
if (!string.IsNullOrEmpty(phone)) q = q.Where(r => r.PhoneNumber == phone);
return Results.Ok(await q.OrderByDescending(r => r.CreatedAt).ToListAsync());
}).RequireAuthorization();
// Doctor reply: set diagnosis + reply text + mark handled
app.MapPut("/api/health-requests/{id:int}/reply", async (int id, DoctorReplyDto dto, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
r.Diagnosis = dto.Diagnosis ?? r.Diagnosis;
r.DoctorReply = dto.DoctorReply ?? r.DoctorReply;
r.IsHandled = true;
r.RepliedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(r);
}).RequireAuthorization();
// Mark handled without reply
app.MapPut("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
@@ -808,6 +850,15 @@ static string Slugify(string text)
return text.Trim('-');
}
static string GenerateTrackingCode()
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
var bytes = new byte[6];
rng.GetBytes(bytes);
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
}
static int EstimateReadingTime(string content)
{
if (string.IsNullOrEmpty(content)) return 1;
+92 -25
View File
@@ -722,7 +722,7 @@ tr:hover td{background:#FAFBFC}
</div>
<div class="table-wrap">
<table>
<thead><tr><th>نام</th><th>تلفن</th><th>دسته</th><th>پیام</th><th>تاریخ</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<thead><tr><th>کد رهگیری</th><th>نام</th><th>تلفن</th><th>دسته</th><th>پیام</th><th>تاریخ</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="healthreqTable"></tbody>
</table>
</div>
@@ -861,7 +861,7 @@ tr:hover td{background:#FAFBFC}
<!-- Visit Modal -->
<!-- Health Request Detail Modal -->
<div class="modal-overlay hidden" id="reqDetailModal">
<div class="modal" style="max-width:560px">
<div class="modal" style="max-width:680px">
<div class="modal-header">
<div class="modal-title">جزئیات درخواست</div>
<button class="modal-close" onclick="closeModal('reqDetailModal')"></button>
@@ -1331,45 +1331,112 @@ async function loadHealthRequests() {
const catLabel = {beauty:'زیبایی پوست', health:'سلامت عمومی'};
document.getElementById('healthreqTable').innerHTML = _allReqs.map(r=>`
<tr style="${!r.isHandled?'font-weight:600':'opacity:.75'}">
<td><span style="font-family:monospace;font-size:.78rem;background:var(--gold-pale);color:var(--gold);padding:2px 7px;border-radius:6px">${r.trackingCode||'—'}</span></td>
<td>${r.fullName}</td>
<td dir="ltr">${r.phoneNumber}</td>
<td>${catLabel[r.category]||r.category}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mid);font-size:.85rem">${r.message||'—'}</td>
<td>${new Date(r.createdAt).toLocaleDateString('fa-IR')}</td>
<td><span style="background:${r.isHandled?'#E8F5E9':'#FFEBEE'};color:${r.isHandled?'#388E3C':'#C62828'};padding:2px 8px;border-radius:20px;font-size:.72rem">${r.isHandled?'بررسی شده':'جدید'}</span></td>
<td dir="ltr" style="font-size:.85rem">${r.phoneNumber}</td>
<td style="font-size:.82rem">${catLabel[r.category]||r.category}</td>
<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mid);font-size:.82rem">${r.message||'—'}</td>
<td style="font-size:.82rem">${new Date(r.createdAt).toLocaleDateString('fa-IR')}</td>
<td><span style="background:${r.isHandled?'#E8F5E9':'#FFEBEE'};color:${r.isHandled?'#388E3C':'#C62828'};padding:2px 8px;border-radius:20px;font-size:.72rem">${r.isHandled?'پاسخ داده شده':'جدید'}</span></td>
<td style="white-space:nowrap">
<button class="btn btn-secondary btn-sm" onclick="viewReq(${r.id})">مشاهده</button>
${!r.isHandled?`<button class="btn btn-secondary btn-sm" onclick="handleReq(${r.id})">✓ بررسی شد</button>`:''}
<button class="btn btn-secondary btn-sm" onclick="viewReq(${r.id})">پاسخ / مشاهده</button>
<button class="btn btn-danger btn-sm" onclick="deleteReq(${r.id})">حذف</button>
</td>
</tr>`).join('') || '<tr><td colspan="7" style="text-align:center;color:var(--light);padding:2rem">درخواستی وجود ندارد</td></tr>';
</tr>`).join('') || '<tr><td colspan="8" style="text-align:center;color:var(--light);padding:2rem">درخواستی وجود ندارد</td></tr>';
}
function viewReq(id) {
function lbl(text) {
return `<span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.3rem">${text}</span>`;
}
async function viewReq(id) {
const r = _allReqs.find(x=>x.id===id); if(!r) return;
const catLabel = {beauty:'زیبایی پوست', health:'سلامت عمومی'};
// Load history for same phone number
const history = await api(`/api/health-requests?phone=${encodeURIComponent(r.phoneNumber)}`) || [];
const histHtml = history.length > 1 ? `
<div style="margin-top:1.5rem">
<div style="font-size:.82rem;font-weight:600;color:var(--mid);margin-bottom:.6rem">📋 سابقه درخواست‌های این شماره (${history.length} درخواست)</div>
<div style="display:flex;flex-direction:column;gap:.5rem">
${history.map(h=>`
<div style="background:${h.id===r.id?'var(--gold-pale)':'var(--section-bg)'};border-radius:10px;padding:.6rem .9rem;font-size:.82rem;display:flex;align-items:center;gap:.8rem;cursor:pointer;border:1.5px solid ${h.id===r.id?'var(--gold)':'transparent'}" onclick="viewReq(${h.id})">
<span style="color:var(--light);flex-shrink:0">${new Date(h.createdAt).toLocaleDateString('fa-IR')}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${(h.message||'').substring(0,60)}${h.message?.length>60?'...':''}</span>
<span style="background:${h.isHandled?'#E8F5E9':'#FFEBEE'};color:${h.isHandled?'#388E3C':'#C62828'};padding:2px 8px;border-radius:20px;font-size:.7rem;flex-shrink:0">${h.isHandled?'پاسخ داده شده':'جدید'}</span>
</div>`).join('')}
</div>
</div>` : '';
document.getElementById('reqDetailBody').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.2rem">
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">نام</span><strong>${r.fullName}</strong></div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">تلفن</span><strong dir="ltr">${r.phoneNumber}</strong></div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">ایمیل</span><span>${r.email||'—'}</span></div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">دسته</span>
<span style="background:${r.category==='health'?'#E3F2FD':'var(--gold-pale)'};color:${r.category==='health'?'#1565C0':'var(--gold)'};padding:3px 10px;border-radius:20px;font-size:.78rem">${catLabel[r.category]||r.category}</span>
<!-- Header: tracking code + status -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.2rem;flex-wrap:wrap;gap:.5rem">
<div style="background:var(--gold-pale);border-radius:10px;padding:.4rem .9rem;font-size:.82rem;font-family:monospace;letter-spacing:.05em;color:var(--gold)">
🔖 کد رهگیری: <strong>${r.trackingCode||'—'}</strong>
</div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">تاریخ ثبت</span><span>${new Date(r.createdAt).toLocaleDateString('fa-IR')}</span></div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.25rem">وضعیت</span>
<span style="background:${r.isHandled?'#E8F5E9':'#FFEBEE'};color:${r.isHandled?'#388E3C':'#C62828'};padding:3px 10px;border-radius:20px;font-size:.78rem">${r.isHandled?'بررسی شده':'جدید'}</span>
<span style="background:${r.isHandled?'#E8F5E9':'#FFEBEE'};color:${r.isHandled?'#388E3C':'#C62828'};padding:4px 12px;border-radius:20px;font-size:.78rem">${r.isHandled?'✓ پاسخ داده شده':'⏳ در انتظار بررسی'}</span>
</div>
<!-- Patient info -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.8rem;margin-bottom:1.2rem">
<div>${lbl('نام')}<strong>${r.fullName}</strong></div>
<div>${lbl('تلفن')}<strong dir="ltr">${r.phoneNumber}</strong></div>
<div>${lbl('دسته')}
<span style="background:${r.category==='health'?'#E3F2FD':'var(--gold-pale)'};color:${r.category==='health'?'#1565C0':'var(--gold)'};padding:2px 10px;border-radius:20px;font-size:.78rem">${catLabel[r.category]||r.category}</span>
</div>
<div>${lbl('تاریخ ثبت')}<span>${new Date(r.createdAt).toLocaleDateString('fa-IR')}</span></div>
</div>
<!-- Patient message -->
<div style="margin-bottom:1.2rem">
${lbl('پیام بیمار')}
<div style="background:var(--section-bg);border-radius:10px;padding:.9rem 1rem;line-height:2;font-size:.88rem;white-space:pre-wrap;min-height:50px">${r.message||'—'}</div>
</div>
<!-- Existing reply (if any) -->
${r.doctorReply||r.diagnosis ? `
<div style="background:#E8F5E9;border-radius:12px;padding:1rem;margin-bottom:1.2rem;border-right:3px solid #388E3C">
<div style="font-size:.78rem;color:#388E3C;font-weight:600;margin-bottom:.5rem">✓ پاسخ دکتر — ${r.repliedAt?new Date(r.repliedAt).toLocaleDateString('fa-IR'):''}</div>
${r.diagnosis?`<div style="margin-bottom:.5rem">${lbl('تشخیص')}<strong>${r.diagnosis}</strong></div>`:''}
${r.doctorReply?`<div>${lbl('پاسخ / توضیح')}<span style="font-size:.88rem">${r.doctorReply}</span></div>`:''}
</div>` : ''}
<!-- Doctor reply form -->
<div style="border-top:1px solid var(--border);padding-top:1.2rem">
<div style="font-size:.88rem;font-weight:600;color:var(--mid);margin-bottom:.8rem">👩‍⚕️ پاسخ پزشک</div>
<div class="form-group" style="margin-bottom:.8rem">
<label style="font-size:.8rem">تشخیص</label>
<input id="reply-diagnosis" value="${r.diagnosis||''}" placeholder="مثال: درماتیت آتوپیک، نیاز به مشاوره حضوری ..."/>
</div>
<div class="form-group" style="margin-bottom:1rem">
<label style="font-size:.8rem">پاسخ / توضیح برای بیمار</label>
<textarea id="reply-body" rows="4" placeholder="پاسخ خود را بنویسید. بیمار می‌تواند با کد رهگیری این پاسخ را ببیند...">${r.doctorReply||''}</textarea>
</div>
<div style="display:flex;gap:.6rem;flex-wrap:wrap">
<button class="btn btn-primary" onclick="saveReply(${r.id})">💾 ذخیره پاسخ</button>
${!r.isHandled?`<button class="btn btn-secondary" onclick="handleReq(${r.id})">✓ بررسی شد (بدون پاسخ)</button>`:''}
<button class="btn btn-secondary" onclick="closeModal('reqDetailModal')">بستن</button>
</div>
</div>
<div><span style="font-size:.75rem;color:var(--light);display:block;margin-bottom:.5rem">پیام / شرح درخواست</span>
<div style="background:var(--section-bg);border-radius:12px;padding:1rem 1.2rem;line-height:2;font-size:.9rem;white-space:pre-wrap;min-height:60px">${r.message||'—'}</div>
</div>
${!r.isHandled?`<div style="margin-top:1.2rem"><button class="btn btn-primary" onclick="handleReq(${r.id});closeModal('reqDetailModal')">✓ علامت‌گذاری به عنوان بررسی شده</button></div>`:''}
${histHtml}
`;
document.getElementById('reqDetailModal').classList.remove('hidden');
}
async function handleReq(id){await api(`/api/health-requests/${id}`,{method:'PUT'});toast('علامت‌گذاری شد ✓');loadHealthRequests();}
async function saveReply(id) {
const diagnosis = document.getElementById('reply-diagnosis').value.trim();
const doctorReply = document.getElementById('reply-body').value.trim();
await api(`/api/health-requests/${id}/reply`, {
method:'PUT',
body: JSON.stringify({diagnosis, doctorReply})
});
toast('پاسخ ذخیره شد ✓');
await loadHealthRequests();
// Refresh view with updated data
viewReq(id);
}
async function handleReq(id){await api(`/api/health-requests/${id}`,{method:'PUT'});toast('علامت‌گذاری شد ✓');loadHealthRequests();closeModal('reqDetailModal');}
async function deleteReq(id){if(!confirm('حذف؟'))return;await api(`/api/health-requests/${id}`,{method:'DELETE'});toast('حذف شد','error');loadHealthRequests();}
// ── Comments ──────────────────────────────────────────────────────────────────