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
+169
View File
@@ -89,6 +89,60 @@ await using (var scope = app.Services.CreateAsyncScope())
""");
}
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);
}
@@ -566,6 +620,121 @@ app.MapDelete("/api/upload/{filename}", (string filename, IWebHostEnvironment en
return Results.NoContent();
}).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 ───────────────────────────────────────────────────────────────────
app.MapGet("/sitemap.xml", async (AppDbContext db, HttpContext ctx) =>
{