feat: patient management system + health landing page
CI/CD / CI · dotnet build (push) Successful in 59s
CI/CD / Deploy · drsousan (push) Successful in 1m33s

Backend:
- Patient model: name, phone, email, age, weight, height, gender,
  blood type, disease history, allergies, medications, notes, category
- PatientVisit model: title, content, prescription, visit type,
  visit/next-visit dates, linked to patient (cascade delete)
- HealthRequest model: public form submissions for beauty/health care
- Runtime SQLite migrations for all 3 new tables
- Full CRUD API: /api/patients, /api/patients/{id}/visits,
  /api/health-requests (public POST + admin GET/PUT/DELETE)

Admin panel:
- 'پرونده بیماران' page: list, search, filter by category (beauty/health)
- Patient profile page: personal info + medical history + visits timeline
- Add/edit patient modal with all medical fields
- Add visit modal: type, date, clinical notes, prescription, next visit
- 'درخواست‌ها' page: manage public health requests, mark as handled
- Badge counter for unhandled requests in sidebar

Frontend (SEO):
- New #health-care section with Schema.org MedicalClinic markup
- Two category cards: زیبایی پوست and سلامت عمومی
- Feature lists with checkmarks per category
- Inline request form that submits to /api/health-request
- Mobile responsive (single column on small screens)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 12:27:16 +03:30
parent 0765d5d3cd
commit 3780dcccf2
5 changed files with 685 additions and 2 deletions
+4 -1
View File
@@ -12,7 +12,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<BlogCategory> BlogCategories => Set<BlogCategory>(); public DbSet<BlogCategory> BlogCategories => Set<BlogCategory>();
public DbSet<BlogPost> BlogPosts => Set<BlogPost>(); public DbSet<BlogPost> BlogPosts => Set<BlogPost>();
public DbSet<Comment> Comments => Set<Comment>(); public DbSet<Comment> Comments => Set<Comment>();
public DbSet<Faq> Faqs => Set<Faq>(); public DbSet<Faq> Faqs => Set<Faq>();
public DbSet<Patient> Patients => Set<Patient>();
public DbSet<PatientVisit> PatientVisits => Set<PatientVisit>();
public DbSet<HealthRequest> HealthRequests => Set<HealthRequest>();
protected override void OnModelCreating(ModelBuilder mb) protected override void OnModelCreating(ModelBuilder mb)
{ {
+50
View File
@@ -130,6 +130,56 @@ public class Faq
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
// ─── Patient ──────────────────────────────────────────────────────────────────
public class Patient
{
public int Id { get; set; }
[MaxLength(150)] public string FullName { get; set; } = "";
[MaxLength(20)] public string PhoneNumber { get; set; } = "";
[MaxLength(200)] public string Email { get; set; } = "";
public int Age { get; set; }
public decimal Weight { get; set; } // kg
public decimal Height { get; set; } // cm
[MaxLength(10)] public string Gender { get; set; } = ""; // مرد / زن
[MaxLength(10)] public string BloodType { get; set; } = "";
public string DiseaseHistory { get; set; } = "";
public string Allergies { get; set; } = "";
public string Medications { get; set; } = "";
public string Notes { get; set; } = "";
[MaxLength(20)] public string Category { get; set; } = "beauty"; // beauty | health
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<PatientVisit> Visits { get; set; } = new List<PatientVisit>();
}
// ─── Patient Visit / Doctor Note ──────────────────────────────────────────────
public class PatientVisit
{
public int Id { get; set; }
public int PatientId { get; set; }
public Patient? Patient { get; set; }
[MaxLength(300)] public string Title { get; set; } = "";
public string Content { get; set; } = "";
public string Prescription { get; set; } = ""; // دارو / تجویز
[MaxLength(50)] public string VisitType { get; set; } = "ویزیت"; // ویزیت | آزمایش | پروسیجر
public DateTime VisitDate { get; set; } = DateTime.UtcNow;
public DateTime? NextVisitDate { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── Health / Appointment Request (public form) ───────────────────────────────
public class HealthRequest
{
public int Id { get; set; }
[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;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
// ─── DTOs ───────────────────────────────────────────────────────────────────── // ─── DTOs ─────────────────────────────────────────────────────────────────────
public record LoginRequest(string Username, string Password); public record LoginRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword); public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
+136
View File
@@ -172,6 +172,29 @@
.faq-item[open] summary::after { content:""; } .faq-item[open] summary::after { content:""; }
.faq-answer { padding:0 1.5rem 1.25rem; color:var(--mid); font-size:0.92rem; line-height:1.85; border-top:1px solid var(--border); } .faq-answer { padding:0 1.5rem 1.25rem; color:var(--mid); font-size:0.92rem; line-height:1.85; border-top:1px solid var(--border); }
/* ─── Contact ──────────────────────────────────────────────── */ /* ─── Contact ──────────────────────────────────────────────── */
/* ─── Health Care Landing ─────────────────────────────────── */
#health-care { background:var(--section-bg); }
.health-header { text-align:center; margin-bottom:3rem; }
.health-cats { display:grid; grid-template-columns:1fr 1fr; gap:2rem; margin-bottom:2.5rem; }
.health-cat-card { background:var(--white); border-radius:24px; padding:2.2rem; border:1px solid var(--border); display:flex; flex-direction:column; gap:1rem; }
.health-cat-icon { width:60px; height:60px; border-radius:18px; display:flex; align-items:center; justify-content:center; }
.health-cat-icon svg { width:28px; height:28px; }
.health-cat-icon.beauty { background:#FCE4EC; color:#C2185B; }
.health-cat-icon.health { background:#E3F2FD; color:#1565C0; }
.health-cat-card h3 { font-size:1.15rem; font-weight:700; color:var(--dark); }
.health-cat-card p { font-size:.88rem; color:var(--mid); line-height:1.8; }
.health-cat-list { list-style:none; padding:0; display:flex; flex-direction:column; gap:.45rem; flex:1; }
.health-cat-list li { font-size:.85rem; color:var(--mid); padding-right:1.2rem; position:relative; }
.health-cat-list li::before { content:"✓"; position:absolute; right:0; color:var(--gold); font-weight:700; }
.health-cta-btn { background:var(--gold); color:#fff; border:none; border-radius:12px; padding:.85rem 1.5rem; font-family:'Vazirmatn',sans-serif; font-size:.9rem; font-weight:600; cursor:pointer; transition:background .25s; margin-top:auto; }
.health-cta-btn:hover { background:#a07840; }
.health-cta-btn.health-btn { background:#1565C0; }
.health-cta-btn.health-btn:hover { background:#0d47a1; }
.health-form-wrap { margin-top:1rem; }
.health-form-card { background:var(--white); border-radius:24px; padding:2.5rem; border:1px solid var(--border); max-width:680px; margin:0 auto; }
.health-form-card h3 { font-size:1.1rem; font-weight:700; margin-bottom:.5rem; }
#health-care .hidden { display:none; }
/* ─── Contact ─────────────────────────────────────────────── */
#contact { background:var(--white); } #contact { background:var(--white); }
.contact-grid { display:grid; grid-template-columns:1fr 1.4fr; gap:4rem; align-items:start; } .contact-grid { display:grid; grid-template-columns:1fr 1.4fr; gap:4rem; align-items:start; }
.contact-info-list { display:flex; flex-direction:column; gap:1.5rem; margin-top:2rem; } .contact-info-list { display:flex; flex-direction:column; gap:1.5rem; margin-top:2rem; }
@@ -227,6 +250,7 @@
.about-image-wrap { text-align:center; } .about-image-wrap { text-align:center; }
.testimonials-grid { grid-template-columns:1fr; } .testimonials-grid { grid-template-columns:1fr; }
.section-title { font-size:1.5rem; } .section-title { font-size:1.5rem; }
.health-cats { grid-template-columns:1fr; }
.faq-item summary { font-size:.9rem; padding:1rem 1.2rem; } .faq-item summary { font-size:.9rem; padding:1rem 1.2rem; }
.faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; } .faq-answer { padding:0 1.2rem 1rem; font-size:.88rem; }
} }
@@ -581,6 +605,72 @@
</div> </div>
</section> </section>
<!-- ══════ HEALTH CARE LANDING ══════ -->
<section id="health-care" itemscope itemtype="https://schema.org/MedicalClinic">
<div class="container">
<div class="health-header fade-in">
<span class="section-label">مراقبت سلامت</span>
<h2 class="section-title" itemprop="name">خدمات پزشکی دکتر آل‌طه</h2>
<div class="divider"></div>
<p class="section-desc" itemprop="description">ما در دو حوزه تخصصی <strong>زیبایی پوست</strong> و <strong>سلامت عمومی</strong> در کنار شما هستیم. از مشاوره اولیه تا پیگیری درمان، تیم ما آماده پاسخگویی است.</p>
</div>
<!-- Category Cards -->
<div class="health-cats fade-in">
<div class="health-cat-card" itemprop="availableService" itemscope itemtype="https://schema.org/MedicalTherapy">
<div class="health-cat-icon beauty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
</div>
<h3 itemprop="name">زیبایی پوست</h3>
<p itemprop="description">بوتاکس، فیلر، لیزر، مزوتراپی، پاکسازی عمیق پوست و درمان تخصصی انواع مشکلات پوستی توسط متخصص.</p>
<ul class="health-cat-list">
<li>تزریق بوتاکس و فیلر</li>
<li>لیزر موهای زائد و جوانسازی</li>
<li>مزوتراپی و PRP</li>
<li>درمان جای جوش و لک</li>
<li>پاکسازی عمیق پوست</li>
</ul>
<button class="health-cta-btn" onclick="openHealthForm('beauty')">درخواست مشاوره زیبایی</button>
</div>
<div class="health-cat-card" itemprop="availableService" itemscope itemtype="https://schema.org/MedicalTherapy">
<div class="health-cat-icon health">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<h3 itemprop="name">سلامت عمومی</h3>
<p itemprop="description">معاینه، تشخیص و پیگیری بیماری‌های عمومی، مشاوره تغذیه، مدیریت وزن و برنامه‌ریزی سلامت فردی.</p>
<ul class="health-cat-list">
<li>معاینه و ویزیت تخصصی</li>
<li>مشاوره و مدیریت وزن</li>
<li>پیگیری بیماری‌های مزمن</li>
<li>برنامه سلامت شخصی‌سازی‌شده</li>
<li>آزمایشات تخصصی</li>
</ul>
<button class="health-cta-btn health-btn" onclick="openHealthForm('health')">درخواست مراقبت سلامت</button>
</div>
</div>
<!-- Health Request Form -->
<div class="health-form-wrap fade-in hidden" id="healthFormWrap">
<div class="health-form-card">
<h3 id="healthFormTitle">درخواست مشاوره</h3>
<p style="color:var(--mid);font-size:.88rem;margin-bottom:1.5rem">فرم زیر را تکمیل کنید. تیم ما در اسرع وقت با شما تماس می‌گیرد.</p>
<input type="hidden" id="hf-category"/>
<div class="form-row">
<div class="form-group"><label>نام و نام خانوادگی *</label><input id="hf-name" placeholder="نام شما" required/></div>
<div class="form-group"><label>شماره موبایل *</label><input id="hf-phone" type="tel" placeholder="09xx-xxx-xxxx" required/></div>
</div>
<div class="form-group"><label>ایمیل (اختیاری)</label><input id="hf-email" type="email" placeholder="email@example.com"/></div>
<div class="form-group"><label>شرح درخواست یا مشکل</label><textarea id="hf-message" rows="4" placeholder="لطفاً مشکل یا درخواست خود را بنویسید ..."></textarea></div>
<div style="display:flex;gap:.8rem;flex-wrap:wrap">
<button class="form-submit" id="hf-submit" onclick="submitHealthForm()" style="flex:1">ارسال درخواست</button>
<button onclick="document.getElementById('healthFormWrap').classList.add('hidden')" style="background:transparent;border:1.5px solid var(--border);border-radius:12px;padding:.8rem 1.5rem;cursor:pointer;font-family:inherit;font-size:.9rem">انصراف</button>
</div>
</div>
</div>
</div>
</section>
<!-- ══════ CONTACT ══════ --> <!-- ══════ CONTACT ══════ -->
<section id="contact"> <section id="contact">
<div class="container"> <div class="container">
@@ -758,6 +848,52 @@
}, 3000); }, 3000);
} }
// Health care request form
function openHealthForm(category) {
document.getElementById('hf-category').value = category;
document.getElementById('healthFormTitle').textContent =
category === 'health' ? 'درخواست مراقبت سلامت عمومی' : 'درخواست مشاوره زیبایی پوست';
const wrap = document.getElementById('healthFormWrap');
wrap.classList.remove('hidden');
setTimeout(() => wrap.scrollIntoView({behavior:'smooth', block:'center'}), 100);
}
async function submitHealthForm() {
const name = document.getElementById('hf-name').value.trim();
const phone = document.getElementById('hf-phone').value.trim();
if (!name || !phone) { alert('نام و شماره تلفن الزامی است'); return; }
const btn = document.getElementById('hf-submit');
btn.textContent = '...در حال ارسال';
btn.disabled = true;
try {
const body = {
fullName: name,
phoneNumber: phone,
email: document.getElementById('hf-email').value,
message: document.getElementById('hf-message').value,
category: document.getElementById('hf-category').value
};
const res = await fetch('/api/health-request', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error();
btn.textContent = '✓ درخواست شما ثبت شد';
btn.style.background = '#2D7A4F';
['hf-name','hf-phone','hf-email','hf-message'].forEach(id => document.getElementById(id).value='');
setTimeout(() => {
document.getElementById('healthFormWrap').classList.add('hidden');
btn.textContent = 'ارسال درخواست';
btn.style.background = '';
btn.disabled = false;
}, 3000);
} catch {
btn.textContent = 'خطا — دوباره تلاش کنید';
btn.style.background = '#c62828';
setTimeout(() => { btn.textContent='ارسال درخواست'; btn.style.background=''; btn.disabled=false; }, 2500);
}
}
// Active nav link on scroll // Active nav link on scroll
const sections = document.querySelectorAll('section[id]'); const sections = document.querySelectorAll('section[id]');
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
+169
View File
@@ -89,6 +89,60 @@ await using (var scope = app.Services.CreateAsyncScope())
"""); """);
} }
catch { /* already exists */ } catch { /* already exists */ }
// Ensure Patient tables exist
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "Patients" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"FullName" TEXT NOT NULL DEFAULT '',
"PhoneNumber" TEXT NOT NULL DEFAULT '',
"Email" TEXT NOT NULL DEFAULT '',
"Age" INTEGER NOT NULL DEFAULT 0,
"Weight" REAL NOT NULL DEFAULT 0,
"Height" REAL NOT NULL DEFAULT 0,
"Gender" TEXT NOT NULL DEFAULT '',
"BloodType" TEXT NOT NULL DEFAULT '',
"DiseaseHistory" TEXT NOT NULL DEFAULT '',
"Allergies" TEXT NOT NULL DEFAULT '',
"Medications" TEXT NOT NULL DEFAULT '',
"Notes" TEXT NOT NULL DEFAULT '',
"Category" TEXT NOT NULL DEFAULT 'beauty',
"IsActive" INTEGER NOT NULL DEFAULT 1,
"CreatedAt" TEXT NOT NULL DEFAULT ''
)
""");
} catch { }
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "PatientVisits" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"PatientId" INTEGER NOT NULL,
"Title" TEXT NOT NULL DEFAULT '',
"Content" TEXT NOT NULL DEFAULT '',
"Prescription" TEXT NOT NULL DEFAULT '',
"VisitType" TEXT NOT NULL DEFAULT 'ویزیت',
"VisitDate" TEXT NOT NULL DEFAULT '',
"NextVisitDate" TEXT,
"CreatedAt" TEXT NOT NULL DEFAULT '',
FOREIGN KEY ("PatientId") REFERENCES "Patients"("Id") ON DELETE CASCADE
)
""");
} catch { }
try {
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS "HealthRequests" (
"Id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"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,
"CreatedAt" TEXT NOT NULL DEFAULT ''
)
""");
} catch { }
await SeedAsync(db); await SeedAsync(db);
} }
@@ -566,6 +620,121 @@ app.MapDelete("/api/upload/{filename}", (string filename, IWebHostEnvironment en
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(); }).RequireAuthorization();
// ── Patients (admin) ──────────────────────────────────────────────────────────
var patientsGroup = app.MapGroup("/api/patients").RequireAuthorization();
patientsGroup.MapGet("/", async (string? category, string? search, AppDbContext db) =>
{
var q = db.Patients.Where(p => p.IsActive);
if (!string.IsNullOrEmpty(category)) q = q.Where(p => p.Category == category);
if (!string.IsNullOrEmpty(search))
q = q.Where(p => p.FullName.Contains(search) || p.PhoneNumber.Contains(search));
return Results.Ok(await q.OrderByDescending(p => p.CreatedAt)
.Select(p => new { p.Id, p.FullName, p.PhoneNumber, p.Email, p.Age, p.Gender,
p.Category, p.BloodType, p.CreatedAt,
VisitCount = db.PatientVisits.Count(v => v.PatientId == p.Id) })
.ToListAsync());
});
patientsGroup.MapGet("/{id:int}", async (int id, AppDbContext db) =>
{
var p = await db.Patients.Include(x => x.Visits.OrderByDescending(v => v.VisitDate))
.FirstOrDefaultAsync(x => x.Id == id);
return p is null ? Results.NotFound() : Results.Ok(p);
});
patientsGroup.MapPost("/", async (Patient patient, AppDbContext db) =>
{
patient.CreatedAt = DateTime.UtcNow;
db.Patients.Add(patient);
await db.SaveChangesAsync();
return Results.Created($"/api/patients/{patient.Id}", patient);
});
patientsGroup.MapPut("/{id:int}", async (int id, Patient updated, AppDbContext db) =>
{
var p = await db.Patients.FindAsync(id);
if (p is null) return Results.NotFound();
p.FullName = updated.FullName; p.PhoneNumber = updated.PhoneNumber;
p.Email = updated.Email; p.Age = updated.Age; p.Weight = updated.Weight;
p.Height = updated.Height; p.Gender = updated.Gender; p.BloodType = updated.BloodType;
p.DiseaseHistory = updated.DiseaseHistory; p.Allergies = updated.Allergies;
p.Medications = updated.Medications; p.Notes = updated.Notes;
p.Category = updated.Category; p.IsActive = updated.IsActive;
await db.SaveChangesAsync();
return Results.Ok(p);
});
patientsGroup.MapDelete("/{id:int}", async (int id, AppDbContext db) =>
{
var p = await db.Patients.FindAsync(id);
if (p is null) return Results.NotFound();
p.IsActive = false; // soft delete
await db.SaveChangesAsync();
return Results.NoContent();
});
// Patient visits/notes
patientsGroup.MapGet("/{id:int}/visits", async (int id, AppDbContext db) =>
Results.Ok(await db.PatientVisits.Where(v => v.PatientId == id)
.OrderByDescending(v => v.VisitDate).ToListAsync()));
patientsGroup.MapPost("/{id:int}/visits", async (int id, PatientVisit visit, AppDbContext db) =>
{
var patient = await db.Patients.FindAsync(id);
if (patient is null) return Results.NotFound();
visit.PatientId = id;
visit.CreatedAt = DateTime.UtcNow;
if (visit.VisitDate == default) visit.VisitDate = DateTime.UtcNow;
db.PatientVisits.Add(visit);
await db.SaveChangesAsync();
return Results.Created($"/api/patients/{id}/visits/{visit.Id}", visit);
});
patientsGroup.MapDelete("/visits/{visitId:int}", async (int visitId, AppDbContext db) =>
{
var v = await db.PatientVisits.FindAsync(visitId);
if (v is null) return Results.NotFound();
db.PatientVisits.Remove(v);
await db.SaveChangesAsync();
return Results.NoContent();
});
// ── Health Requests (public submit / admin manage) ────────────────────────────
app.MapPost("/api/health-request", async (HealthRequest req, AppDbContext db) =>
{
req.CreatedAt = DateTime.UtcNow;
req.IsHandled = false;
db.HealthRequests.Add(req);
await db.SaveChangesAsync();
return Results.Ok(new { message = "درخواست شما ثبت شد" });
});
app.MapGet("/api/health-requests", async (bool? handled, AppDbContext db) =>
{
var q = db.HealthRequests.AsQueryable();
if (handled.HasValue) q = q.Where(r => r.IsHandled == handled);
return Results.Ok(await q.OrderByDescending(r => r.CreatedAt).ToListAsync());
}).RequireAuthorization();
app.MapPut("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
r.IsHandled = true;
await db.SaveChangesAsync();
return Results.Ok(r);
}).RequireAuthorization();
app.MapDelete("/api/health-requests/{id:int}", async (int id, AppDbContext db) =>
{
var r = await db.HealthRequests.FindAsync(id);
if (r is null) return Results.NotFound();
db.HealthRequests.Remove(r);
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization();
// ── Sitemap ─────────────────────────────────────────────────────────────────── // ── Sitemap ───────────────────────────────────────────────────────────────────
app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) => app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) =>
{ {
+326 -1
View File
@@ -269,6 +269,15 @@ tr:hover td{background:#FAFBFC}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
گزارش SEO گزارش SEO
</div> </div>
<div class="nav-section">مدیریت بیماران</div>
<div class="nav-item" onclick="showPage('patients',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
پرونده بیماران
</div>
<div class="nav-item" onclick="showPage('healthrequests',this)" id="healthreqNavItem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 13.6a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 3h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L7.91 10.6a16 16 0 0 0 6 6l.91-.91a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
درخواست‌ها <span id="healthreqBadge" style="display:none;background:#E53935;color:#fff;font-size:.65rem;padding:.1rem .4rem;border-radius:50px;margin-right:.3rem"></span>
</div>
<div class="nav-section">تنظیمات</div> <div class="nav-section">تنظیمات</div>
<div class="nav-item" onclick="showPage('security',this)"> <div class="nav-item" onclick="showPage('security',this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
@@ -549,6 +558,84 @@ tr:hover td{background:#FAFBFC}
</div> </div>
</div> </div>
<!-- ══ PATIENTS PAGE ══ -->
<div class="page" id="page-patients">
<div class="card" style="margin-bottom:1.2rem">
<div class="card-header">
<div class="card-title">پرونده بیماران</div>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<select id="patientCatFilter" onchange="loadPatients()" style="border:1.5px solid var(--border);border-radius:8px;padding:.4rem .7rem;font-family:inherit;font-size:.82rem;background:var(--white)">
<option value="">همه دسته‌ها</option>
<option value="beauty">زیبایی پوست</option>
<option value="health">سلامت عمومی</option>
</select>
<input id="patientSearch" placeholder="جستجو نام / تلفن..." oninput="loadPatients()"
style="border:1.5px solid var(--border);border-radius:8px;padding:.4rem .8rem;font-family:inherit;font-size:.82rem;outline:none;width:180px"/>
<button class="btn btn-primary btn-sm" onclick="openPatientModal()">+ ثبت بیمار جدید</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>نام</th><th>تلفن</th><th>سن</th><th>جنسیت</th><th>دسته</th><th>ویزیت‌ها</th><th>عملیات</th></tr></thead>
<tbody id="patientsTable"></tbody>
</table>
</div>
</div>
</div>
<!-- ══ PATIENT PROFILE (sub-page) ══ -->
<div class="page" id="page-patient-profile">
<div style="display:flex;align-items:center;gap:.8rem;margin-bottom:1.2rem">
<button class="btn btn-secondary btn-sm" onclick="showPage('patients',document.querySelector('.nav-item:has(+[onclick*=patients])'))">← بازگشت</button>
<h2 id="profileName" style="font-size:1.1rem;font-weight:700"></h2>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.2rem">
<!-- Profile card -->
<div class="card">
<div class="card-header"><div class="card-title">اطلاعات شخصی</div>
<button class="btn btn-secondary btn-sm" id="editPatientBtn" onclick="">✏️ ویرایش</button>
</div>
<div class="modal-body" id="profileInfo"></div>
</div>
<!-- Medical history -->
<div class="card">
<div class="card-header"><div class="card-title">سابقه پزشکی</div></div>
<div class="modal-body" id="profileMedical"></div>
</div>
</div>
<!-- Visits timeline -->
<div class="card" style="margin-top:1.2rem">
<div class="card-header">
<div class="card-title">سوابق ویزیت</div>
<button class="btn btn-primary btn-sm" onclick="openVisitModal()">+ ثبت ویزیت جدید</button>
</div>
<div id="visitsTimeline" style="padding:1.2rem"></div>
</div>
</div>
<!-- ══ HEALTH REQUESTS PAGE ══ -->
<div class="page" id="page-healthrequests">
<div class="card">
<div class="card-header">
<div class="card-title">درخواست‌های مراقبت سلامت</div>
<div style="display:flex;gap:.5rem">
<select id="reqFilter" onchange="loadHealthRequests()" style="border:1.5px solid var(--border);border-radius:8px;padding:.4rem .7rem;font-family:inherit;font-size:.82rem;background:var(--white)">
<option value="">همه</option>
<option value="false">بررسی نشده</option>
<option value="true">بررسی شده</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="loadHealthRequests()">🔄 بازخوانی</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>نام</th><th>تلفن</th><th>دسته</th><th>پیام</th><th>تاریخ</th><th>وضعیت</th><th>عملیات</th></tr></thead>
<tbody id="healthreqTable"></tbody>
</table>
</div>
</div>
</div>
<!-- ══ COMMENTS PAGE ══ --> <!-- ══ COMMENTS PAGE ══ -->
<div class="page" id="page-comments"> <div class="page" id="page-comments">
<div class="card"> <div class="card">
@@ -640,6 +727,71 @@ tr:hover td{background:#FAFBFC}
</div> </div>
</div> </div>
<!-- Patient Modal -->
<div class="modal-overlay hidden" id="patientModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title" id="patientModalTitle">ثبت بیمار جدید</div>
<button class="modal-close" onclick="closeModal('patientModal')"></button>
</div>
<div class="modal-body">
<input type="hidden" id="pt-id"/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.8rem">
<div class="form-group"><label>نام و نام خانوادگی *</label><input id="pt-name" placeholder="نام کامل"/></div>
<div class="form-group"><label>شماره تلفن *</label><input id="pt-phone" dir="ltr" placeholder="09xx"/></div>
<div class="form-group"><label>ایمیل</label><input id="pt-email" dir="ltr" placeholder="email@example.com"/></div>
<div class="form-group"><label>سن</label><input id="pt-age" type="number" min="0" max="120" placeholder="سال"/></div>
<div class="form-group"><label>وزن (kg)</label><input id="pt-weight" type="number" step="0.1" placeholder="kg"/></div>
<div class="form-group"><label>قد (cm)</label><input id="pt-height" type="number" step="0.1" placeholder="cm"/></div>
<div class="form-group"><label>جنسیت</label>
<select id="pt-gender"><option value="">انتخاب کنید</option><option value="زن">زن</option><option value="مرد">مرد</option></select>
</div>
<div class="form-group"><label>گروه خونی</label>
<select id="pt-blood"><option value="">نامشخص</option><option>A+</option><option>A-</option><option>B+</option><option>B-</option><option>AB+</option><option>AB-</option><option>O+</option><option>O-</option></select>
</div>
<div class="form-group"><label>دسته درمانی</label>
<select id="pt-cat"><option value="beauty">زیبایی پوست</option><option value="health">سلامت عمومی</option></select>
</div>
</div>
<div class="form-group"><label>سابقه بیماری</label><textarea id="pt-history" rows="3" placeholder="دیابت، فشار خون، بیماری قلبی و ..."></textarea></div>
<div class="form-group"><label>حساسیت‌ها</label><textarea id="pt-allergy" rows="2" placeholder="حساسیت به دارو یا مواد غذایی ..."></textarea></div>
<div class="form-group"><label>داروهای جاری</label><textarea id="pt-meds" rows="2" placeholder="داروهایی که بیمار مصرف می‌کند ..."></textarea></div>
<div class="form-group"><label>یادداشت کلی</label><textarea id="pt-notes" rows="2" placeholder="نکات مهم ..."></textarea></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="savePatient()">ذخیره</button>
<button class="btn btn-secondary" onclick="closeModal('patientModal')">انصراف</button>
</div>
</div>
</div>
<!-- Visit Modal -->
<div class="modal-overlay hidden" id="visitModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">ثبت ویزیت / یادداشت پزشکی</div>
<button class="modal-close" onclick="closeModal('visitModal')"></button>
</div>
<div class="modal-body">
<input type="hidden" id="vt-patientId"/>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.8rem">
<div class="form-group"><label>عنوان ویزیت</label><input id="vt-title" placeholder="مثال: ویزیت اول، جلسه لیزر ۲ ..."/></div>
<div class="form-group"><label>نوع</label>
<select id="vt-type"><option>ویزیت</option><option>آزمایش</option><option>پروسیجر</option><option>مشاوره</option><option>پیگیری</option></select>
</div>
<div class="form-group"><label>تاریخ ویزیت</label><input id="vt-date" type="datetime-local"/></div>
<div class="form-group"><label>ویزیت بعدی</label><input id="vt-next" type="date" placeholder="اختیاری"/></div>
</div>
<div class="form-group"><label>شرح حال / یافته‌ها</label><textarea id="vt-content" rows="4" placeholder="معاینه، شکایت بیمار، یافته‌های بالینی ..."></textarea></div>
<div class="form-group"><label>تجویز / دستورالعمل</label><textarea id="vt-rx" rows="3" placeholder="دارو، دوز، توصیه‌ها ..."></textarea></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="saveVisit()">ذخیره ویزیت</button>
<button class="btn btn-secondary" onclick="closeModal('visitModal')">انصراف</button>
</div>
</div>
</div>
<!-- Gallery Modal --> <!-- Gallery Modal -->
<div class="modal-overlay hidden" id="galleryModal"> <div class="modal-overlay hidden" id="galleryModal">
<div class="modal"> <div class="modal">
@@ -889,7 +1041,7 @@ function toast(msg, type='success') {
} }
// ── Navigation ──────────────────────────────────────────────────────────────── // ── Navigation ────────────────────────────────────────────────────────────────
const pageTitles = {dashboard:'داشبورد',hero:'صفحه اصلی',about:'درباره من',contact:'تماس',services:'خدمات',gallery:'گالری',testimonials:'نظرات',blogposts:'مقالات',categories:'دسته‌بندی‌ها',faqs:'سوالات متداول',seo:'گزارش SEO',comments:'مدیریت نظرات',security:'تغییر رمز عبور'}; const pageTitles = {dashboard:'داشبورد',hero:'صفحه اصلی',about:'درباره من',contact:'تماس',services:'خدمات',gallery:'گالری',testimonials:'نظرات',blogposts:'مقالات',categories:'دسته‌بندی‌ها',faqs:'سوالات متداول',seo:'گزارش SEO',comments:'مدیریت نظرات',security:'تغییر رمز عبور',patients:'پرونده بیماران','patient-profile':'پرونده بیمار',healthrequests:'درخواست‌های سلامت'};
function showPage(name, el) { function showPage(name, el) {
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
@@ -913,8 +1065,180 @@ function loadPage(name) {
else if (name==='faqs') loadFaqs(); else if (name==='faqs') loadFaqs();
else if (name==='seo') loadSeo(); else if (name==='seo') loadSeo();
else if (name==='comments') loadComments(); else if (name==='comments') loadComments();
else if (name==='patients') loadPatients();
else if (name==='healthrequests') loadHealthRequests();
} }
// ── Patients ──────────────────────────────────────────────────────────────────
let patients = [], currentPatientId = null;
async function loadPatients() {
const cat = document.getElementById('patientCatFilter').value;
const search = document.getElementById('patientSearch').value;
const params = new URLSearchParams();
if (cat) params.set('category', cat);
if (search) params.set('search', search);
patients = await api('/api/patients?' + params) || [];
const catLabel = {beauty:'زیبایی پوست', health:'سلامت عمومی'};
document.getElementById('patientsTable').innerHTML = patients.map(p => `
<tr>
<td><strong>${p.fullName}</strong></td>
<td dir="ltr">${p.phoneNumber}</td>
<td>${p.age || '-'}</td>
<td>${p.gender || '-'}</td>
<td><span style="background:${p.category==='health'?'#E3F2FD':'#FCE4EC'};color:${p.category==='health'?'#1565C0':'#880E4F'};padding:2px 8px;border-radius:20px;font-size:.72rem">${catLabel[p.category]||p.category}</span></td>
<td>${p.visitCount}</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="openProfile(${p.id})">پرونده</button>
<button class="btn btn-secondary btn-sm" onclick="editPatient(${p.id})">ویرایش</button>
<button class="btn btn-danger btn-sm" onclick="deletePatient(${p.id})">حذف</button>
</td>
</tr>`).join('') || '<tr><td colspan="7" style="text-align:center;color:var(--light);padding:2rem">بیماری ثبت نشده</td></tr>';
}
function openPatientModal() {
['id','name','phone','email','age','weight','height'].forEach(f => document.getElementById(`pt-${f}`).value='');
['gender','blood','cat'].forEach(f => document.getElementById(`pt-${f}`).selectedIndex=0);
['history','allergy','meds','notes'].forEach(f => document.getElementById(`pt-${f}`).value='');
document.getElementById('patientModalTitle').textContent='ثبت بیمار جدید';
document.getElementById('patientModal').classList.remove('hidden');
}
function editPatient(id) {
const p = patients.find(x=>x.id===id); if(!p) return;
document.getElementById('pt-id').value=p.id;
document.getElementById('pt-name').value=p.fullName||'';
document.getElementById('pt-phone').value=p.phoneNumber||'';
document.getElementById('pt-email').value=p.email||'';
document.getElementById('pt-age').value=p.age||'';
document.getElementById('pt-weight').value=p.weight||'';
document.getElementById('pt-height').value=p.height||'';
document.getElementById('pt-gender').value=p.gender||'';
document.getElementById('pt-blood').value=p.bloodType||'';
document.getElementById('pt-cat').value=p.category||'beauty';
document.getElementById('patientModalTitle').textContent='ویرایش بیمار';
document.getElementById('patientModal').classList.remove('hidden');
}
async function savePatient() {
const id = document.getElementById('pt-id').value;
const body={
fullName:document.getElementById('pt-name').value,
phoneNumber:document.getElementById('pt-phone').value,
email:document.getElementById('pt-email').value,
age:parseInt(document.getElementById('pt-age').value)||0,
weight:parseFloat(document.getElementById('pt-weight').value)||0,
height:parseFloat(document.getElementById('pt-height').value)||0,
gender:document.getElementById('pt-gender').value,
bloodType:document.getElementById('pt-blood').value,
diseaseHistory:document.getElementById('pt-history').value,
allergies:document.getElementById('pt-allergy').value,
medications:document.getElementById('pt-meds').value,
notes:document.getElementById('pt-notes').value,
category:document.getElementById('pt-cat').value,
isActive:true
};
if(id) await api(`/api/patients/${id}`,{method:'PUT',body:JSON.stringify(body)});
else await api('/api/patients',{method:'POST',body:JSON.stringify(body)});
closeModal('patientModal'); toast('ذخیره شد ✓'); loadPatients();
}
async function deletePatient(id){if(!confirm('حذف بیمار؟'))return;await api(`/api/patients/${id}`,{method:'DELETE'});toast('حذف شد','error');loadPatients();}
// Patient Profile
async function openProfile(id) {
currentPatientId = id;
const p = await api(`/api/patients/${id}`);
if(!p) return;
document.getElementById('profileName').textContent = p.fullName + (p.category==='health' ? ' — سلامت عمومی' : ' — زیبایی پوست');
document.getElementById('editPatientBtn').onclick = () => editPatient(id);
document.getElementById('profileInfo').innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.7rem;font-size:.88rem">
<div><b>تلفن:</b> <span dir="ltr">${p.phoneNumber||'-'}</span></div>
<div><b>ایمیل:</b> ${p.email||'-'}</div>
<div><b>سن:</b> ${p.age||'-'}</div>
<div><b>جنسیت:</b> ${p.gender||'-'}</div>
<div><b>وزن:</b> ${p.weight||'-'} kg</div>
<div><b>قد:</b> ${p.height||'-'} cm</div>
<div><b>گروه خونی:</b> ${p.bloodType||'-'}</div>
<div><b>تاریخ ثبت:</b> ${new Date(p.createdAt).toLocaleDateString('fa-IR')}</div>
</div>
${p.notes ? `<p style="margin-top:.8rem;color:var(--mid);font-size:.85rem">${p.notes}</p>` : ''}
`;
document.getElementById('profileMedical').innerHTML = `
<div style="font-size:.88rem;line-height:2">
<div><b>سابقه بیماری:</b><br><span style="color:var(--mid)">${p.diseaseHistory||'—'}</span></div>
<div style="margin-top:.8rem"><b>حساسیت‌ها:</b><br><span style="color:var(--mid)">${p.allergies||'—'}</span></div>
<div style="margin-top:.8rem"><b>داروها:</b><br><span style="color:var(--mid)">${p.medications||'—'}</span></div>
</div>
`;
renderVisits(p.visits||[]);
showPage('patient-profile', null);
}
function renderVisits(visits) {
const tl = document.getElementById('visitsTimeline');
if(!visits.length){tl.innerHTML='<p style="color:var(--light);text-align:center;padding:2rem">ویزیتی ثبت نشده</p>';return;}
const typeColors={'ویزیت':'#1976D2','آزمایش':'#388E3C','پروسیجر':'#7B1FA2','مشاوره':'#F57C00','پیگیری':'#0288D1'};
tl.innerHTML = visits.map(v => `
<div style="border-right:3px solid ${typeColors[v.visitType]||'#999'};padding:.8rem 1rem .8rem 0;margin-bottom:1.2rem;padding-right:1rem">
<div style="display:flex;align-items:center;gap:.6rem;margin-bottom:.4rem">
<span style="background:${typeColors[v.visitType]||'#999'};color:#fff;padding:2px 10px;border-radius:20px;font-size:.72rem">${v.visitType}</span>
<strong style="font-size:.9rem">${v.title}</strong>
<span style="color:var(--light);font-size:.78rem;margin-right:auto">${new Date(v.visitDate).toLocaleDateString('fa-IR')}</span>
<button class="btn btn-danger btn-sm" style="padding:.2rem .6rem;font-size:.7rem" onclick="deleteVisit(${v.id})">حذف</button>
</div>
${v.content?`<p style="font-size:.85rem;color:var(--dark);margin:.3rem 0">${v.content}</p>`:''}
${v.prescription?`<div style="background:#F8F9FA;border-radius:8px;padding:.5rem .8rem;font-size:.82rem;margin-top:.4rem;color:#333"><b>تجویز:</b> ${v.prescription}</div>`:''}
${v.nextVisitDate?`<p style="font-size:.78rem;color:var(--gold);margin-top:.4rem">📅 ویزیت بعدی: ${new Date(v.nextVisitDate).toLocaleDateString('fa-IR')}</p>`:''}
</div>`).join('');
}
function openVisitModal() {
document.getElementById('vt-patientId').value=currentPatientId;
['title','content','rx'].forEach(f=>document.getElementById(`vt-${f}`).value='');
document.getElementById('vt-type').selectedIndex=0;
document.getElementById('vt-date').value=new Date().toISOString().slice(0,16);
document.getElementById('vt-next').value='';
document.getElementById('visitModal').classList.remove('hidden');
}
async function saveVisit() {
const pid = document.getElementById('vt-patientId').value;
const body={
title:document.getElementById('vt-title').value,
content:document.getElementById('vt-content').value,
prescription:document.getElementById('vt-rx').value,
visitType:document.getElementById('vt-type').value,
visitDate:document.getElementById('vt-date').value||new Date().toISOString(),
nextVisitDate:document.getElementById('vt-next').value||null
};
await api(`/api/patients/${pid}/visits`,{method:'POST',body:JSON.stringify(body)});
closeModal('visitModal'); toast('ویزیت ثبت شد ✓');
openProfile(parseInt(pid));
}
async function deleteVisit(vid){if(!confirm('حذف ویزیت؟'))return;await api(`/api/patients/visits/${vid}`,{method:'DELETE'});toast('حذف شد','error');openProfile(currentPatientId);}
// ── Health Requests ───────────────────────────────────────────────────────────
async function loadHealthRequests() {
const filter = document.getElementById('reqFilter').value;
const params = filter !== '' ? `?handled=${filter}` : '';
const reqs = await api('/api/health-requests' + params) || [];
const pending = reqs.filter(r=>!r.isHandled).length;
const badge = document.getElementById('healthreqBadge');
if(pending>0){badge.textContent=pending;badge.style.display='inline';}else{badge.style.display='none';}
const catLabel = {beauty:'زیبایی پوست', health:'سلامت عمومی'};
document.getElementById('healthreqTable').innerHTML = reqs.map(r=>`
<tr style="${!r.isHandled?'font-weight:600':'opacity:.7'}">
<td>${r.fullName}</td>
<td dir="ltr">${r.phoneNumber}</td>
<td>${catLabel[r.category]||r.category}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.message}">${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>
${!r.isHandled?`<button class="btn btn-secondary btn-sm" onclick="handleReq(${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>';
}
async function handleReq(id){await api(`/api/health-requests/${id}`,{method:'PUT'});toast('علامت‌گذاری شد ✓');loadHealthRequests();}
async function deleteReq(id){if(!confirm('حذف؟'))return;await api(`/api/health-requests/${id}`,{method:'DELETE'});toast('حذف شد','error');loadHealthRequests();}
// ── Comments ────────────────────────────────────────────────────────────────── // ── Comments ──────────────────────────────────────────────────────────────────
async function loadComments() { async function loadComments() {
const all = await api('/api/comments') || []; const all = await api('/api/comments') || [];
@@ -1738,6 +2062,7 @@ async function init(){
loadDashboard(); loadDashboard();
loadCategories(); loadCategories();
loadComments(); // populate pending badge on load loadComments(); // populate pending badge on load
loadHealthRequests(); // populate health requests badge on load
} }
// Auto-login if token exists // Auto-login if token exists