refactor: remove DaisyUI and implement custom theme system
- Remove DaisyUI dependency from package.json - Implement custom CSS components (btn, badge, navbar, menu, card, footer) - Add dark/light theme toggle with localStorage persistence - Update color scheme to dark blue palette (primary: blue, secondary: cyan, accent: electric blue) - Make blog pages full-width by removing max-width constraints - Restore sidebar navigation for clqms posts with full-width layout - Fix navbar menu alignment issues with flexbox BREAKING CHANGE: DaisyUI classes replaced with custom implementations
This commit is contained in:
parent
b3a1323368
commit
ec37ccc9bb
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -11,7 +11,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@11ty/eleventy": "^3.1.2",
|
"@11ty/eleventy": "^3.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"daisyui": "^5.5.14",
|
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
@ -1073,16 +1072,6 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/daisyui": {
|
|
||||||
"version": "5.5.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz",
|
|
||||||
"integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "5panda.11ty",
|
"name": "5panda.11ty",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "5Panda Portfolio, Blog & Documentation site built with 11ty, Tailwind CSS v4 and daisyUI v5",
|
"description": "5Panda Portfolio, Blog & Documentation site built with 11ty and Tailwind CSS v4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm-run-all --parallel dev:*",
|
"dev": "npm-run-all --parallel dev:*",
|
||||||
"dev:11ty": "eleventy --serve",
|
"dev:11ty": "eleventy --serve",
|
||||||
@ -15,15 +15,13 @@
|
|||||||
"blog",
|
"blog",
|
||||||
"docs",
|
"docs",
|
||||||
"11ty",
|
"11ty",
|
||||||
"tailwindcss",
|
"tailwindcss"
|
||||||
"daisyui"
|
|
||||||
],
|
],
|
||||||
"author": "5Panda",
|
"author": "5Panda",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@11ty/eleventy": "^3.1.2",
|
"@11ty/eleventy": "^3.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"daisyui": "^5.5.14",
|
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="panda">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@ -16,7 +16,19 @@
|
|||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
rel="stylesheet"> <!-- Styles -->
|
rel="stylesheet"> <!-- Styles -->
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link
|
||||||
|
rel="stylesheet" href="/css/style.css">
|
||||||
|
<!-- Theme initialization script (must be in head to prevent FOUC) -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="min-h-screen bg-base-100 text-base-content">
|
class="min-h-screen bg-base-100 text-base-content">
|
||||||
@ -58,7 +70,7 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<ul class="p-2 bg-base-100 rounded-t-none bg-base-100/95 backdrop-blur-xl border border-white/5 shadow-xl w-60 z-[100]">
|
<ul class="p-2 bg-base-100 rounded-t-none bg-base-100/95 backdrop-blur-xl border border-white/5 shadow-xl w-60 z-[100]">
|
||||||
{% for post in collections.posts %}
|
{% for post in collections.posts %}
|
||||||
{% if 'clqms' not in post.data.tags %}
|
{% if post.data.tags and 'clqms' not in post.data.tags %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ post.url }}" class="{% if page.url == post.url %}text-primary bg-primary/10{% endif %} hover:text-primary hover:bg-primary/10">
|
<a href="{{ post.url }}" class="{% if page.url == post.url %}text-primary bg-primary/10{% endif %} hover:text-primary hover:bg-primary/10">
|
||||||
{{ post.data.title }}
|
{{ post.data.title }}
|
||||||
@ -74,15 +86,14 @@
|
|||||||
<div
|
<div
|
||||||
class="navbar-end">
|
class="navbar-end">
|
||||||
<!-- Theme toggle -->
|
<!-- Theme toggle -->
|
||||||
<label class="swap swap-rotate btn btn-ghost btn-circle btn-sm">
|
<button id="theme-toggle" class="btn btn-ghost btn-circle btn-sm theme-toggle" aria-label="Toggle theme">
|
||||||
<input type="checkbox" class="theme-controller" value="pandaLight"/>
|
<svg class="icon-sun h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||||
<svg class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
|
||||||
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
|
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
<svg class="icon-moon h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24">
|
||||||
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
|
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</label>
|
</button>
|
||||||
<!-- GitHub link -->
|
<!-- GitHub link -->
|
||||||
<a href="https://github.com" target="_blank" class="btn btn-ghost btn-circle btn-sm">
|
<a href="https://github.com" target="_blank" class="btn btn-ghost btn-circle btn-sm">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24">
|
||||||
@ -128,8 +139,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<aside>
|
<aside>
|
||||||
<p class="text-sm text-base-content/40">© {% year %} 5Panda. Built with 11ty, Tailwind CSS & daisyUI.</p>
|
<p class="text-sm text-base-content/40">© {% year %} 5Panda. Built with 11ty & Tailwind CSS.</p>
|
||||||
</aside>
|
</aside>
|
||||||
</footer>
|
</footer>
|
||||||
|
<!-- Theme toggle script -->
|
||||||
|
<script>
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', function () {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const currentTheme = html.getAttribute('data-theme');
|
||||||
|
const newTheme = currentTheme === 'light'
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
html.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -2,11 +2,10 @@
|
|||||||
layout: base.njk
|
layout: base.njk
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="section-container py-12 animate-slide-up">
|
<div class="w-full px-4 sm:px-6 lg:px-8 py-12 animate-slide-up">
|
||||||
<div
|
<div class="flex flex-col lg:flex-row gap-12">
|
||||||
class="flex flex-col lg:flex-row gap-12">
|
|
||||||
<!-- Sidebar Navigation -->
|
<!-- Sidebar Navigation -->
|
||||||
<aside class="lg:w-1/4">
|
<aside class="lg:w-64 flex-shrink-0">
|
||||||
<div class="sticky top-24 bg-base-200/50 backdrop-blur-xl border border-white/5 rounded-2xl p-6">
|
<div class="sticky top-24 bg-base-200/50 backdrop-blur-xl border border-white/5 rounded-2xl p-6">
|
||||||
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
|
<h3 class="font-bold text-lg mb-4 flex items-center gap-2">
|
||||||
<svg
|
<svg
|
||||||
@ -41,7 +40,7 @@ layout: base.njk
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="lg:w-3/4 min-w-0">
|
<main class="flex-1 min-w-0">
|
||||||
<article>
|
<article>
|
||||||
<!-- Post header -->
|
<!-- Post header -->
|
||||||
<header class="mb-10">
|
<header class="mb-10">
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
---
|
---
|
||||||
layout: base.njk
|
layout: base.njk
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="section-container py-12 animate-slide-up">
|
<article class="section-container py-12 animate-slide-up">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div>
|
||||||
<!-- Post header -->
|
<!-- Post header -->
|
||||||
<header class="mb-10">
|
<header class="mb-10">
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
@ -16,7 +17,11 @@ layout: base.njk
|
|||||||
<div class="flex flex-wrap items-center gap-4 text-base-content/60">
|
<div class="flex flex-wrap items-center gap-4 text-base-content/60">
|
||||||
<time datetime="{{ date | dateFormat('iso') }}" class="flex items-center gap-2">
|
<time datetime="{{ date | dateFormat('iso') }}" class="flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ date | dateFormat('full') }}
|
{{ date | dateFormat('full') }}
|
||||||
</time>
|
</time>
|
||||||
@ -28,12 +33,10 @@ layout: base.njk
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Post content -->
|
<!-- Post content -->
|
||||||
<div class="prose-custom max-w-none">
|
<div class="prose-custom max-w-none">
|
||||||
{{ content | safe }}
|
{{ content | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post footer -->
|
<!-- Post footer -->
|
||||||
<footer class="mt-12 pt-8 border-t border-white/10">
|
<footer class="mt-12 pt-8 border-t border-white/10">
|
||||||
<a href="/blog/" class="btn btn-outline btn-primary gap-2">
|
<a href="/blog/" class="btn btn-outline btn-primary gap-2">
|
||||||
|
|||||||
563
src/blog/clqms-roast-Opus.md
Normal file
563
src/blog/clqms-roast-Opus.md
Normal file
@ -0,0 +1,563 @@
|
|||||||
|
---
|
||||||
|
title: "Database Design Roast: Claude Opus"
|
||||||
|
description: "A professional roast of the CLQMS database schema."
|
||||||
|
date: 2025-12-12
|
||||||
|
order: 6
|
||||||
|
tags:
|
||||||
|
- posts
|
||||||
|
- clqms
|
||||||
|
layout: clqms-post.njk
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔥 CLQMS Database Schema: A Professional Roast 🔥
|
||||||
|
|
||||||
|
|
||||||
|
> *"I've seen spaghetti code before, but this is spaghetti architecture."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
After reviewing the CLQMS (Clinical Laboratory Quality Management System) database schema, I regret to inform you that this is not a database design — it's a **crime scene**. Someone read *"Design Patterns for Dummies"* and decided to implement ALL of them. Simultaneously. Incorrectly.
|
||||||
|
|
||||||
|
Let me walk you through this masterpiece of over-engineering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🗑️ The ValueSet Anti-Pattern: Where Enums Go to Die
|
||||||
|
|
||||||
|
### What's Happening
|
||||||
|
|
||||||
|
```
|
||||||
|
valueset → VID, SiteID, VSetID, VValue, VDesc, VCategory
|
||||||
|
valuesetdef → VSetID, VSName, VSDesc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every. Single. Enum.** In the entire system is crammed into ONE table:
|
||||||
|
- Gender
|
||||||
|
- Country
|
||||||
|
- Religion
|
||||||
|
- Ethnicity
|
||||||
|
- Marital Status
|
||||||
|
- Death Indicator
|
||||||
|
- Location Type
|
||||||
|
- Test Type
|
||||||
|
- Site Type
|
||||||
|
- Site Class
|
||||||
|
- And probably what you had for lunch
|
||||||
|
|
||||||
|
### Why This Is Catastrophic
|
||||||
|
|
||||||
|
1. **Zero Type Safety**
|
||||||
|
```sql
|
||||||
|
-- This is totally valid and will execute without error:
|
||||||
|
UPDATE patient SET Gender =
|
||||||
|
(SELECT VID FROM valueset WHERE VDesc = 'Hospital' AND VSetID = 'LocationType')
|
||||||
|
```
|
||||||
|
Congratulations, your patient is now a hospital.
|
||||||
|
|
||||||
|
2. **The Join Apocalypse**
|
||||||
|
Getting a single patient with readable values requires this:
|
||||||
|
```php
|
||||||
|
->join('valueset country', 'country.VID = patient.Country', 'left')
|
||||||
|
->join('valueset race', 'race.VID = patient.Race', 'left')
|
||||||
|
->join('valueset religion', 'religion.VID = patient.Religion', 'left')
|
||||||
|
->join('valueset ethnic', 'ethnic.VID = patient.Ethnic', 'left')
|
||||||
|
->join('valueset gender', 'gender.VID = patient.Gender', 'left')
|
||||||
|
->join('valueset deathindicator', 'deathindicator.VID = patient.DeathIndicator', 'left')
|
||||||
|
->join('valueset maritalstatus', 'maritalstatus.VID = patient.MaritalStatus', 'left')
|
||||||
|
```
|
||||||
|
That's **7 joins to the same table** for ONE patient query. Your database server is crying.
|
||||||
|
|
||||||
|
3. **No Easy Identification**
|
||||||
|
- Primary key is `VID` — an auto-increment integer
|
||||||
|
- No natural key enforcement
|
||||||
|
- You want "Male"? Good luck remembering if it's VID 47 or VID 174
|
||||||
|
|
||||||
|
4. **Maintenance Nightmare**
|
||||||
|
- Adding a new Gender option? Better hope you remember the correct `VSetID`
|
||||||
|
- Want to rename "Other" to "Non-Binary"? Hope you update it in the right row
|
||||||
|
- Database constraints? What are those?
|
||||||
|
|
||||||
|
### What Sane People Do
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Option A: Actual ENUMs (MySQL)
|
||||||
|
ALTER TABLE patient ADD COLUMN gender ENUM('M', 'F', 'O', 'U');
|
||||||
|
|
||||||
|
-- Option B: Dedicated lookup tables with meaningful constraints
|
||||||
|
CREATE TABLE gender (
|
||||||
|
code VARCHAR(2) PRIMARY KEY,
|
||||||
|
description VARCHAR(50) NOT NULL
|
||||||
|
);
|
||||||
|
ALTER TABLE patient ADD FOREIGN KEY (gender_code) REFERENCES gender(code);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 🪆 Organization: The Matryoshka Nightmare
|
||||||
|
|
||||||
|
### The Current "Architecture"
|
||||||
|
|
||||||
|
```
|
||||||
|
Account → has Parent (self-referencing)
|
||||||
|
↳ has Sites
|
||||||
|
↳ Site → has Parent (self-referencing)
|
||||||
|
↳ has SiteTypeID → valueset
|
||||||
|
↳ has SiteClassID → valueset
|
||||||
|
↳ has Departments
|
||||||
|
↳ Department → has DisciplineID
|
||||||
|
↳ Discipline → has Parent (self-referencing)
|
||||||
|
|
||||||
|
Plus: Workstations exist somewhere in this chaos
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Philosophical Question
|
||||||
|
|
||||||
|
**What IS an organization in this system?**
|
||||||
|
|
||||||
|
| Entity | Has Parent? | Has Site? | Has Account? | Is Self-Referencing? |
|
||||||
|
|--------|-------------|-----------|--------------|----------------------|
|
||||||
|
| Account | ✅ | ❌ | ❌ | ✅ |
|
||||||
|
| Site | ✅ | ❌ | ✅ | ✅ |
|
||||||
|
| Department | ❌ | ✅ | ❌ | ❌ |
|
||||||
|
| Discipline | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
|
So to understand organizational hierarchy, you need to:
|
||||||
|
1. Traverse Account's parent chain
|
||||||
|
2. For each Account, get Sites
|
||||||
|
3. Traverse each Site's parent chain
|
||||||
|
4. For each Site, get Departments AND Disciplines
|
||||||
|
5. Traverse each Discipline's parent chain
|
||||||
|
6. Oh and don't forget Workstations
|
||||||
|
|
||||||
|
You basically need a graph database to query what should be a simple org chart.
|
||||||
|
|
||||||
|
### What Normal Systems Do
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE organization (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
parent_id INT REFERENCES organization(id),
|
||||||
|
type ENUM('company', 'site', 'department', 'discipline'),
|
||||||
|
name VARCHAR(255),
|
||||||
|
code VARCHAR(50)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**One table. One parent reference. Done.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 📍 Location + LocationAddress: The Pointless Split
|
||||||
|
|
||||||
|
### The Crime
|
||||||
|
|
||||||
|
```
|
||||||
|
location → LocationID, SiteID, LocCode, Parent, LocFull, LocType
|
||||||
|
locationaddress → LocationID, Street1, Street2, City, Province, PostCode, GeoLocation
|
||||||
|
```
|
||||||
|
|
||||||
|
**LocationAddress uses LocationID as both Primary Key AND Foreign Key.**
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Every location has **exactly one** address (1:1 relationship)
|
||||||
|
- You cannot have a location without an address
|
||||||
|
- You cannot have multiple addresses per location
|
||||||
|
|
||||||
|
### Evidence of the Crime
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function saveLocation(array $data): array {
|
||||||
|
$db->transBegin();
|
||||||
|
try {
|
||||||
|
if (!empty($data['LocationID'])) {
|
||||||
|
$this->update($LocationID, $data);
|
||||||
|
$modelAddress->update($LocationID, $data); // <-- Always update BOTH
|
||||||
|
} else {
|
||||||
|
$LocationID = $this->insert($data, true);
|
||||||
|
$modelAddress->insert($data); // <-- Always insert BOTH
|
||||||
|
}
|
||||||
|
$db->transCommit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You **always** have to save both tables in a transaction. Because they are fundamentally **ONE entity**.
|
||||||
|
|
||||||
|
### The Verdict
|
||||||
|
|
||||||
|
If data is always created, updated, and deleted together — **IT BELONGS IN THE SAME TABLE**.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Just combine them:
|
||||||
|
CREATE TABLE location (
|
||||||
|
location_id INT PRIMARY KEY,
|
||||||
|
site_id INT,
|
||||||
|
loc_code VARCHAR(50),
|
||||||
|
loc_full VARCHAR(255),
|
||||||
|
loc_type INT,
|
||||||
|
street1 VARCHAR(255),
|
||||||
|
street2 VARCHAR(255),
|
||||||
|
city INT,
|
||||||
|
province INT,
|
||||||
|
post_code VARCHAR(20),
|
||||||
|
geo_location_system VARCHAR(50),
|
||||||
|
geo_location_data TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
You just saved yourself a transaction, a join, and 50% of the headache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 👨⚕️ Contact vs Doctor: The Identity Crisis
|
||||||
|
|
||||||
|
### The Confusion
|
||||||
|
|
||||||
|
```
|
||||||
|
contact → ContactID, NameFirst, NameLast, Specialty, SubSpecialty, Phone...
|
||||||
|
contactdetail → ContactDetID, ContactID, SiteID, OccupationID, JobTitle, Department...
|
||||||
|
occupation → OccupationID, OccupationName...
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Questions Nobody Can Answer
|
||||||
|
|
||||||
|
1. **Is a Contact a Doctor?**
|
||||||
|
- Contact has `Specialty` and `SubSpecialty` fields (doctor-specific)
|
||||||
|
- But also has generic `OccupationID` via ContactDetail
|
||||||
|
- So a Contact is Maybe-A-Doctor™?
|
||||||
|
|
||||||
|
2. **What prevents non-doctors from being assigned as doctors?**
|
||||||
|
|
||||||
|
In `patvisitadt`:
|
||||||
|
```php
|
||||||
|
'AttDoc', 'RefDoc', 'AdmDoc', 'CnsDoc' // Attending, Referring, Admitting, Consulting
|
||||||
|
```
|
||||||
|
|
||||||
|
These store ContactIDs. But there's **zero validation** that these contacts are actually doctors. Your receptionist could be the Attending Physician and the database would happily accept it.
|
||||||
|
|
||||||
|
3. **Why does ContactDetail exist?**
|
||||||
|
- It stores the same person's info **per site**
|
||||||
|
- So one person can have different roles at different sites
|
||||||
|
- But `Specialty` is on Contact (not ContactDetail), so a doctor has the same specialty everywhere?
|
||||||
|
- Except `OccupationID` is on ContactDetail, so their occupation changes per site?
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE person (
|
||||||
|
person_id INT PRIMARY KEY,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
-- ... basic personal info
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE doctor (
|
||||||
|
doctor_id INT PRIMARY KEY REFERENCES person(person_id),
|
||||||
|
specialty VARCHAR(100),
|
||||||
|
subspecialty VARCHAR(100),
|
||||||
|
license_number VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE site_staff (
|
||||||
|
person_id INT REFERENCES person(person_id),
|
||||||
|
site_id INT REFERENCES site(site_id),
|
||||||
|
role ENUM('doctor', 'nurse', 'technician', 'admin'),
|
||||||
|
PRIMARY KEY (person_id, site_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can actually **enforce** that only doctors are assigned to doctor fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 🏥 Patient Data: The Table Explosion
|
||||||
|
|
||||||
|
### The Current State
|
||||||
|
|
||||||
|
| Table | Purpose | Why It Exists |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
| `patient` | Main patient (30+ columns) | Fair enough |
|
||||||
|
| `patidt` | Patient Identifiers | One identifier per patient |
|
||||||
|
| `patcom` | Patient Comments | ONE comment per patient (why a whole table?) |
|
||||||
|
| `patatt` | Patient "Attachments"... or Addresses? | Stores `Address` as a single string |
|
||||||
|
|
||||||
|
### The Crimes
|
||||||
|
|
||||||
|
#### Crime 1: Duplicate Address Storage
|
||||||
|
|
||||||
|
`patient` table already has:
|
||||||
|
```
|
||||||
|
Street_1, Street_2, Street_3, City, Province, ZIP
|
||||||
|
```
|
||||||
|
|
||||||
|
`patatt` table stores:
|
||||||
|
```
|
||||||
|
Address (as a single string)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why do we have both?** Nobody knows. Pick one and commit.
|
||||||
|
|
||||||
|
#### Crime 2: One Table for ONE Comment
|
||||||
|
|
||||||
|
```php
|
||||||
|
class PatComModel {
|
||||||
|
protected $allowedFields = ['InternalPID', 'Comment', 'CreateDate', 'EndDate'];
|
||||||
|
|
||||||
|
public function createPatCom(string $patcom, string $newInternalPID) {
|
||||||
|
$this->insert(["InternalPID" => $newInternalPID, "Comment" => $patcom]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A whole table. Foreign key constraints. Model class. CRUD operations. **For one text field.**
|
||||||
|
|
||||||
|
Just add `comment TEXT` to the patient table.
|
||||||
|
|
||||||
|
#### Crime 3: CSV in a Relational Database
|
||||||
|
|
||||||
|
```php
|
||||||
|
$patient['LinkTo'] = '1,5,23,47'; // Comma-separated patient IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function getLinkedPatients(?string $linkTo): ?array {
|
||||||
|
$ids = array_filter(explode(',', $linkTo)); // Oh no
|
||||||
|
return $this->db->table('patient')->whereIn('InternalPID', $ids)->get();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**It's 2025.** Use a junction table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE patient_link (
|
||||||
|
patient_id INT,
|
||||||
|
linked_patient_id INT,
|
||||||
|
link_type VARCHAR(50),
|
||||||
|
PRIMARY KEY (patient_id, linked_patient_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🤮 Patient Admission (ADT): Event Sourcing Gone Wrong
|
||||||
|
|
||||||
|
### The Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
patvisit → InternalPVID, PVID, InternalPID (the visit)
|
||||||
|
patvisitadt → PVADTID, InternalPVID, ADTCode, LocationID, AttDoc... (ADT events)
|
||||||
|
patdiag → InternalPVID, DiagCode, Diagnosis
|
||||||
|
```
|
||||||
|
|
||||||
|
Instead of tracking patient status with simple fields, every Admission/Discharge/Transfer creates a **new row**.
|
||||||
|
|
||||||
|
### The Query From Hell
|
||||||
|
|
||||||
|
To get the current status of a patient visit:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->join('(SELECT a1.*
|
||||||
|
FROM patvisitadt a1
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT InternalPVID, MAX(PVADTID) AS MaxID
|
||||||
|
FROM patvisitadt
|
||||||
|
GROUP BY InternalPVID
|
||||||
|
) a2 ON a1.InternalPVID = a2.InternalPVID AND a1.PVADTID = a2.MaxID
|
||||||
|
) AS patvisitadt',
|
||||||
|
'patvisitadt.InternalPVID = patvisit.InternalPVID',
|
||||||
|
'left')
|
||||||
|
```
|
||||||
|
|
||||||
|
Every. Single. Query. To get current patient status.
|
||||||
|
|
||||||
|
### The Performance Analysis
|
||||||
|
|
||||||
|
| Rows in patvisitadt | Query Complexity |
|
||||||
|
|---------------------|------------------|
|
||||||
|
| 1,000 | Meh, fine |
|
||||||
|
| 10,000 | Getting slow |
|
||||||
|
| 100,000 | Coffee break |
|
||||||
|
| 1,000,000 | Go home |
|
||||||
|
|
||||||
|
### What You Should Do
|
||||||
|
|
||||||
|
**Option A: Just use status fields**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE patvisit ADD COLUMN current_status ENUM('admitted', 'discharged', 'transferred');
|
||||||
|
ALTER TABLE patvisit ADD COLUMN current_location_id INT;
|
||||||
|
ALTER TABLE patvisit ADD COLUMN current_attending_doctor_id INT;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: If you NEED history, use proper triggers**
|
||||||
|
```sql
|
||||||
|
CREATE TRIGGER update_current_status
|
||||||
|
AFTER INSERT ON patvisitadt
|
||||||
|
FOR EACH ROW
|
||||||
|
UPDATE patvisit SET current_status = NEW.adt_code WHERE InternalPVID = NEW.InternalPVID;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 🧪 Test Definitions: The Abbreviation Cemetery
|
||||||
|
|
||||||
|
### The Tables
|
||||||
|
|
||||||
|
| Table | What Is This? | Fields |
|
||||||
|
|-------|---------------|--------|
|
||||||
|
| `testdefsite` | Test per Site | TestSiteID, TestSiteCode, TestSiteName, SeqScr, SeqRpt, VisibleScr, VisibleRpt... |
|
||||||
|
| `testdefgrp` | Test Groups | TestGrpID, TestSiteID, Member |
|
||||||
|
| `testdefcal` | Calculated Tests | TestCalID, TestSiteID, DisciplineID, DepartmentID... |
|
||||||
|
| `testdeftech` | Technical Details | TestTechID, TestSiteID, DisciplineID, DepartmentID... |
|
||||||
|
| `testmap` | ??? | TestMapID, TestSiteID... |
|
||||||
|
| `refnum` | Numeric Reference Ranges | RefNumID, TestSiteID, Sex, AgeStart, AgeEnd, Low, High... |
|
||||||
|
| `reftxt` | Text Reference Ranges | RefTxtID, TestSiteID, Sex, AgeStart, AgeEnd, RefTxt... |
|
||||||
|
| `refvset` | ValueSet Reference | RefVSetID, TestSiteID... |
|
||||||
|
| `refthold` | Thresholds | RefTHoldID... |
|
||||||
|
|
||||||
|
### The Abbreviation Apocalypse
|
||||||
|
|
||||||
|
| Abbreviation | Meaning | Guessability |
|
||||||
|
|--------------|---------|--------------|
|
||||||
|
| `SeqScr` | Sequence Screen | 2/10 |
|
||||||
|
| `SeqRpt` | Sequence Report | 3/10 |
|
||||||
|
| `SpcType` | Specimen Type | 4/10 |
|
||||||
|
| `VID` | ValueSet ID | 1/10 |
|
||||||
|
| `InternalPID` | Internal Patient ID | 5/10 |
|
||||||
|
| `InternalPVID` | Internal Patient Visit ID | 4/10 |
|
||||||
|
| `PVADTID` | Patient Visit ADT ID | 0/10 |
|
||||||
|
| `TestDefCal` | Test Definition Calculation | 3/10 |
|
||||||
|
| `RefTHold` | Reference Threshold | 1/10 |
|
||||||
|
|
||||||
|
**Pro tip:** If new developers need a glossary to understand your schema, you've failed.
|
||||||
|
|
||||||
|
### The Split Between RefNum and RefTxt
|
||||||
|
|
||||||
|
For numeric tests: use `refnum`
|
||||||
|
For text tests: use `reftxt`
|
||||||
|
|
||||||
|
Why not:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE reference_range (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
test_id INT,
|
||||||
|
range_type ENUM('numeric', 'text'),
|
||||||
|
low_value DECIMAL,
|
||||||
|
high_value DECIMAL,
|
||||||
|
text_value VARCHAR(255),
|
||||||
|
-- ... other fields
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
One table. One query. One life.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 🎭 Bonus Round: Sins I Couldn't Ignore
|
||||||
|
|
||||||
|
### Sin #1: Inconsistent Soft Delete Field Names
|
||||||
|
|
||||||
|
| Table | Delete Field | Why Different? |
|
||||||
|
|-------|--------------|----------------|
|
||||||
|
| Most tables | `EndDate` | ??? |
|
||||||
|
| patient | `DelDate` | ??? |
|
||||||
|
| patatt | `DelDate` | ??? |
|
||||||
|
| patvisitadt | `EndDate` AND `ArchivedDate` AND `DelDate` | ¯\\\_(ツ)\_/¯ |
|
||||||
|
|
||||||
|
### Sin #2: Primary Key With a Trailing Space
|
||||||
|
|
||||||
|
In `PatComModel.php`:
|
||||||
|
```php
|
||||||
|
protected $primaryKey = 'PatComID '; // <-- THERE IS A SPACE HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
This has either:
|
||||||
|
- Never been tested
|
||||||
|
- Works by pure accident
|
||||||
|
- Will explode randomly one day
|
||||||
|
|
||||||
|
### Sin #3: Inconsistent ID Naming
|
||||||
|
|
||||||
|
| Column | Location |
|
||||||
|
|--------|----------|
|
||||||
|
| `InternalPID` | patient, patatt, patcom, patidt, patvisit... |
|
||||||
|
| `PatientID` | Also on patient table |
|
||||||
|
| `ContactID` | contact |
|
||||||
|
| `ContactDetID` | contactdetail |
|
||||||
|
| `VID` | valueset |
|
||||||
|
| `VSetID` | Also valueset and valuesetdef |
|
||||||
|
| `TestSiteID` | testdefsite |
|
||||||
|
| `TestGrpID` | testdefgrp |
|
||||||
|
|
||||||
|
Pick a convention. ANY convention. Please.
|
||||||
|
|
||||||
|
### Sin #4: Multiple Date Tracking Fields with Unclear Purposes
|
||||||
|
|
||||||
|
On `testdefsite`:
|
||||||
|
- `CreateDate` — when created
|
||||||
|
- `StartDate` — when... started? Different from created how?
|
||||||
|
- `EndDate` — when ended (soft delete)
|
||||||
|
|
||||||
|
### Sin #5: No Data Validation
|
||||||
|
|
||||||
|
The `patient` model has 30+ fields including:
|
||||||
|
- `Gender` (valueset VID — could be literally anything)
|
||||||
|
- `Religion` (valueset VID — could be a LocationType)
|
||||||
|
- `DeathIndicator` (valueset VID — could be a Gender)
|
||||||
|
|
||||||
|
Zero database-level constraints. Zero model-level validation. Pure vibes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 The Final Scorecard
|
||||||
|
|
||||||
|
| Category | Rating | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| **Normalization** | 2/10 | Either over-normalized (LocationAddress) or under-normalized (CSV in columns) |
|
||||||
|
| **Consistency** | 1/10 | Every table is a unique snowflake |
|
||||||
|
| **Performance** | 3/10 | Those MAX subqueries and 7-way joins will age poorly |
|
||||||
|
| **Maintainability** | 1/10 | Good luck onboarding new developers |
|
||||||
|
| **Type Safety** | 0/10 | ValueSet is a type-safety black hole |
|
||||||
|
| **Naming** | 2/10 | Abbreviation chaos |
|
||||||
|
| **Scalability** | 2/10 | Event-sourcing-but-wrong will not scale |
|
||||||
|
|
||||||
|
**Overall: 1.5/10** — *"At least the tables exist"*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Recommendations
|
||||||
|
|
||||||
|
If you want to fix this (and you should), here's the priority order:
|
||||||
|
|
||||||
|
1. **Eliminate the ValueSet monster** — Replace with proper ENUMs or dedicated lookup tables with foreign key constraints
|
||||||
|
|
||||||
|
2. **Combine 1:1 tables** — Location + LocationAddress, Patient + PatCom (if it's really just one comment)
|
||||||
|
|
||||||
|
3. **Fix Patient data model** — Proper junction tables for PatIdt, PatAtt, and LinkTo
|
||||||
|
|
||||||
|
4. **Add current status to PatVisit** — Denormalize the ADT current state
|
||||||
|
|
||||||
|
5. **Standardize naming** — Pick `*_id`, `*ID`, or `Id` and stick with it. Pick `end_date` or `del_date` for soft deletes.
|
||||||
|
|
||||||
|
6. **Add actual constraints** — Foreign keys that make sense. Check constraints. Not just vibes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Conclusion
|
||||||
|
|
||||||
|
This schema is what happens when someone:
|
||||||
|
- Prioritizes "flexibility" over usability
|
||||||
|
- Learns about normalization but not when to stop
|
||||||
|
- Discovers self-referencing tables and uses them everywhere
|
||||||
|
- Thinks abbreviations save storage space (they don't)
|
||||||
|
- Has never had to maintain their own code
|
||||||
|
|
||||||
|
The good news: it can be fixed.
|
||||||
|
The bad news: it should have been designed correctly the first time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document prepared with 🔥 and ☕*
|
||||||
|
|
||||||
|
*May your database queries be fast and your schemas be sane.*
|
||||||
170
src/blog/clqms-roast-zai.md
Normal file
170
src/blog/clqms-roast-zai.md
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
title: "Database Design Roast"
|
||||||
|
description: "A legendary roast of the CLQMS database schema, highlighting the architectural hazards and data hell."
|
||||||
|
date: 2026-01-07
|
||||||
|
order: 7
|
||||||
|
tags:
|
||||||
|
- posts
|
||||||
|
- clqms
|
||||||
|
layout: clqms-post.njk
|
||||||
|
---
|
||||||
|
|
||||||
|
# The Database Design from Hell: A Comprehensive Roast
|
||||||
|
## *Or: Why your current project makes you want to quit.*
|
||||||
|
|
||||||
|
You are right to be sick of this. This document is a masterclass in how **not** to design a database. It takes simple concepts and wraps them in layers of unnecessary, redundant, and contradictory complexity.
|
||||||
|
|
||||||
|
Here is the systematic destruction of every data relation and design choice that is causing you pain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The "Value Set" Disaster (The God Table)
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
Storing every single enumeration (Dropdown list, Flag, Reference Text, Status) in one giant generic table (`ValueSet` / `codedtxtfld`).
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **No Identity:** You mentioned "don't have an easy identifier." This is because they likely didn't use a Surrogate Key (an auto-incrementing ID). They probably used the `Code` (e.g., "M" for Male) as the Primary Key.
|
||||||
|
* *Why it sucks:* If you ever need to change "M" to "Male", you break every single Foreign Key in the database that points to it. You can't update a key that is being used elsewhere. Your data is frozen in time forever.
|
||||||
|
* **Performance Killer:** Imagine loading a dropdown for "Gender." The database has to scan a table containing 10,000 rows (all flags, all statuses, all colors, all text results) just to find "M" and "F".
|
||||||
|
* **Zero Integrity:** Because it's a generic table, you can't enforce specific rules. You can accidentally delete a value used for "Critical Patient Status" because the table thinks it's just a generic string. There is no referential integrity. It's the Wild West.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The "Organization" Nightmare
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
Attempting to model a global conglomerate structure of Accounts, Sites, Disciplines, Departments, Workstations, and Parent-Child hierarchies.
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **Over-Engineering:** You are building a database for a Laboratory, not the United Nations. Why does a lab system need a recursive `Account` structure that handles "Parent/Child" relationships for companies?
|
||||||
|
* **The Blob:** `Account` comes from CRM, but `Site` comes from CRM, yet `Discipline` is internal. You are mixing external business logic (Sales) with internal operational logic (Lab Science).
|
||||||
|
* **Identity Confusion:** Is a "Department" a physical place? A group of people? Or a billing category? In this design, it's all three simultaneously. This makes generating a simple report like "Who works in Hematology?" a complex query involving `Organization`, `Personnel`, and `Location`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The "Location" & "LocationAddress" Split
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
Separating the Location definition (Name, Type) from its Address (Street, City) into two different tables linked 1-to-1.
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **The "Why?" Factor:** Why? Is a Bed (Location) going to have multiple addresses? No. Is a Building going to have multiple addresses? No.
|
||||||
|
* **Performance Tax:** Every single time you need to print a label or show where a sample is, you **must** perform a `JOIN`.
|
||||||
|
* *Bad Design:* `SELECT * FROM Location l JOIN LocationAddress a ON l.id = a.id`
|
||||||
|
* **The Null Nightmare:** For mobile locations (Home Care), the address is vital. For static locations (Bed 1), the address is meaningless (it's just coordinates relative to the room). By forcing a split, you either have empty rows in `LocationAddress` or you have to invent fake addresses for beds. It's pointless normalization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The "HostApp" Table
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
A specific table dedicated to defining external applications (`HostApp`) that integrate with the system.
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **The Myth of Modularity:** This table pretends the system is "plug-and-play" with other apps. But look at the Appendices: "Calibration Results SQL Scripts." That's hard-coded SQL, not a dynamic plugin.
|
||||||
|
* **Maintenance Hell:** This table implies you need to map every single field from every external app.
|
||||||
|
* *Scenario:* Hospital A uses HIS "MediTech". Hospital B uses HIS "CarePoint".
|
||||||
|
* *Result:* You need a `HostApp` row for MediTech and one for CarePoint. Then you need mapping tables for Patient, Order, Result, etc. You are building an ETL (Extract, Transform, Load) tool inside a Lab database. It's out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The "Doctor" vs "Contact" Loop
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
A `Contact` table that stores generic people, and a `Doctor` table that... also stores people? Or does it reference Contact?
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **The Infinite Join:** To get a Doctor's name, do you query `Doctor` or `Contact`?
|
||||||
|
* If `Doctor` extends `Contact` (1-to-1), you have to join every time.
|
||||||
|
* If `Doctor` is just a row in `Contact` with a `Type='Doctor'`, why does the `Doctor` table exist?
|
||||||
|
* **Semantic Mess:** A "Contact" usually implies "How to reach." A "Doctor" implies "Medical License."
|
||||||
|
* **The Failure:** If Dr. Smith retires, do you delete the `Doctor` record? Yes. But then you delete his `Contact` info, so you lose his phone number for historical records. This design doesn't separate the *Role* (Doctor) from the *Entity* (Person). It's a data integrity nightmare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Patient Data: The Actual Hell
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
Storing Patients, Non-Patients (Blood bags), External QC, and linking/unlinking them all in one messy structure.
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **Blood Bags are not Humans:**
|
||||||
|
* The document explicitly says "mengelola non-patient entity... blood bag."
|
||||||
|
* *Result:* Your `Patient` table has `DateOfBirth` (Required) and `BloodType` (Required). For a Blood Bag, DOB is NULL. For a Human, BloodType might be NULL.
|
||||||
|
* You have created a "Sparse Table" (50% NULLs). It's impossible to index effectively. It breaks the very definition of what a "Patient" is.
|
||||||
|
* **The "Link/Unlink" Suicide Pact:**
|
||||||
|
* "Menghubungkan (link)/mengurai(unlink) data pasien."
|
||||||
|
* *Audit Trail Death:* If I link "John Doe" from Site A to "John Doe" from Site B, and then later "Unlink" them, the database loses the history of that decision. Why did we unlink them? Was it a mistake? The design doesn't track the *decision*, it just changes the data.
|
||||||
|
* **Confusion:** `Patient Registration` vs `Patient Admission`. Why are these two different giant workflows? In every other system on earth, you Register (create ID) and Admit (start visit). This document treats them like they require NASA-level calculations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Patient Admission: Revenue Cycle Virulence
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
Admission is tightly coupled with "Pihak yang menanggung biaya" (Payer) and "Tarif" (Price).
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **It's a Billing System, not a Lab System:** This section reads like an Accounting module. The Lab database should not care if the patient pays with BlueCross or Cash.
|
||||||
|
* **The Logic Trap:** If the Admission fails because the "Tarif" (Price) is missing, does the Lab stop processing the blood sample?
|
||||||
|
* *According to this design:* Probably yes.
|
||||||
|
* *In reality:* The patient could be dying. Clinical safety should never be blocked by administrative billing data. Mixing these concerns is dangerous.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Data: The Maze of Redundancy
|
||||||
|
|
||||||
|
**The Design:**
|
||||||
|
`testdef`, `testdefsite`, `testdeftech`, `testgrp`, `refnum`, `reftxt`, `fixpanel`.
|
||||||
|
|
||||||
|
**The Roast:**
|
||||||
|
* **Definition Explosion:**
|
||||||
|
* `testdefsite`: Defines "Glucose" for Site A.
|
||||||
|
* `testdeftech`: Defines "Glucose" for Machine B.
|
||||||
|
* *Reality:* Glucose is Glucose. The chemical reaction doesn't change because the building is different.
|
||||||
|
* *Cost:* You have to update the Reference Range for Glucose in 50 different places if the lab director decides to change it.
|
||||||
|
* **The "Group" Soup:**
|
||||||
|
* `Profile` (One tube), `Functional Procedure` (Time series), `Superset` (Billing).
|
||||||
|
* These are stored in `testgrp` as if they are the same thing.
|
||||||
|
* *Failure:** You can't enforce logic. The system allows you to add a "2-Hour Post-Prandial" (Time-based) to a "Lipid Panel" (One-tube) because to the database, they are just "Tests in a Group."
|
||||||
|
* **`refnum` vs `reftxt`:**
|
||||||
|
* Why split them? A Reference Range is data. Whether it's "10-20" (Numeric) or "Positive/Negative" (Text) is just a formatting rule. Splitting them into two tables doubles your JOINs and complicates the query logic for no reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. BONUS: Other Disasters I Found
|
||||||
|
|
||||||
|
### A. Equipment & The "Mousepad" Tracking
|
||||||
|
**The Roast:**
|
||||||
|
The document defines Equipment as: "termasuk UPS, AVR, printer, PC... mouse, keyboard."
|
||||||
|
* **The Trap:** Do you really need a depreciation schedule and maintenance log for a mouse? By lumping "IVD Analyzers" (Critical Medical Devices) with "Computer Mice" (Office Supplies), you clutter the Equipment table with garbage data.
|
||||||
|
* **Fix:** Separate `CapitalAsset` (Machines) from `InventoryItem` (Supplies).
|
||||||
|
|
||||||
|
### B. Specimen: The "Parent" Trap
|
||||||
|
**The Roast:**
|
||||||
|
Secondary Specimen has a `ParentID` pointing to Primary Specimen.
|
||||||
|
* **The Void:** There is no tracking of volume. If Tube A (Parent) has 5ml, and you create Tube B (Child/Aliquot) with 1ml, the database does not know that Tube A now only has 4ml left.
|
||||||
|
* **The Consequence:** You cannot do automated inventory. You can't alert the user "Running low on sample!" because the database thinks the sample is infinite.
|
||||||
|
|
||||||
|
### C. The "Red Font" Encryption
|
||||||
|
**The Roast:**
|
||||||
|
"Encrypted = Red Font."
|
||||||
|
* **The Joke:** This is technically illiterate. A database stores bytes. It does not store "Red Font."
|
||||||
|
* **The Risk:** If they literally store `<b><span color='red'>PatientName</span></b>` in the database, you have corrupted your data.
|
||||||
|
* **The Reality:** If it's just a UI setting, why is it in the database requirements section? It proves the writer doesn't know the difference between Data Storage and Data Presentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: Why you want to vomit
|
||||||
|
|
||||||
|
This document is a "Jack of All Trades, Master of None."
|
||||||
|
It tries to be:
|
||||||
|
1. **CRM** (Contact/Account mgmt)
|
||||||
|
2. **ERP** (Inventory/Asset mgmt)
|
||||||
|
3. **Billing System** (Admission/Tariff)
|
||||||
|
4. **Laboratory Information System** (The actual work)
|
||||||
|
|
||||||
|
By trying to do all of this in a single, poorly normalized schema, it creates a **Data Hell** where nothing is reliable, nothing is fast, and changing one thing breaks three others.
|
||||||
|
|
||||||
|
**Do not build this "as is."** If you do, you will spend the next 5 years writing `SQL Scripts` to patch the holes in this sinking ship.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
title: "Project Pandaria: Next-Gen LIS Architecture"
|
title: "Project Pandaria: Next-Gen LIS Architecture"
|
||||||
description: "An offline-first, event-driven architecture concept for the CLQMS."
|
description: "An offline-first, event-driven architecture concept for the CLQMS."
|
||||||
date: 2025-12-19
|
date: 2025-12-19
|
||||||
order: 6
|
order: 8
|
||||||
tags:
|
tags:
|
||||||
- posts
|
- posts
|
||||||
- clqms
|
- clqms
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
title: "Edge Workstation: SQLite Database Schema"
|
title: "Edge Workstation: SQLite Database Schema"
|
||||||
description: "Database design for the offline-first smart workstation."
|
description: "Database design for the offline-first smart workstation."
|
||||||
date: 2025-12-19
|
date: 2025-12-19
|
||||||
order: 7
|
order: 9
|
||||||
tags:
|
tags:
|
||||||
- posts
|
- posts
|
||||||
- clqms
|
- clqms
|
||||||
|
|||||||
@ -16,7 +16,7 @@ description: Our projects and technical showcase
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Proposals List -->
|
<!-- Proposals List -->
|
||||||
<div class="max-w-3xl mx-auto space-y-6">
|
<div class="space-y-6">
|
||||||
{% for post in collections.posts %}
|
{% for post in collections.posts %}
|
||||||
<a href="{{ post.url }}" class="post-card block group">
|
<a href="{{ post.url }}" class="post-card block group">
|
||||||
<div class="flex flex-col md:flex-row md:items-start gap-4">
|
<div class="flex flex-col md:flex-row md:items-start gap-4">
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
THEME CONFIGURATION
|
THEME CONFIGURATION
|
||||||
@ -62,78 +61,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
DAISYUI THEME CUSTOMIZATION
|
CSS CUSTOM PROPERTIES - THEME COLORS
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
@plugin "daisyui" {
|
:root {
|
||||||
themes: panda --default, pandaLight;
|
/* Default: Dark theme with blue accents */
|
||||||
}
|
--color-base-100: oklch(0.15 0.02 250);
|
||||||
|
--color-base-200: oklch(0.18 0.025 250);
|
||||||
|
--color-base-300: oklch(0.25 0.03 250);
|
||||||
|
--color-base-content: oklch(0.95 0.01 250);
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
--color-primary: oklch(0.70 0.20 240);
|
||||||
name: "panda";
|
--color-primary-content: oklch(1 0 0);
|
||||||
default: true;
|
--color-secondary: oklch(0.75 0.18 200);
|
||||||
/* Fun dark theme - cozy night with pops of color */
|
--color-secondary-content: oklch(0.15 0.02 200);
|
||||||
--color-base-100: oklch(0.18 0.015 280);
|
--color-accent: oklch(0.65 0.25 260);
|
||||||
/* Deep purple-ish dark */
|
--color-accent-content: oklch(1 0 0);
|
||||||
--color-base-200: oklch(0.22 0.02 280);
|
--color-neutral: oklch(0.28 0.02 250);
|
||||||
/* Slightly lighter */
|
--color-neutral-content: oklch(0.92 0.01 250);
|
||||||
--color-base-300: oklch(0.30 0.025 280);
|
|
||||||
/* Cards and borders */
|
|
||||||
--color-base-content: oklch(0.95 0.01 280);
|
|
||||||
/* Crisp white text */
|
|
||||||
--color-primary: oklch(0.75 0.18 15);
|
|
||||||
/* 🧡 Coral/Peach - warm and fun */
|
|
||||||
--color-primary-content: oklch(0.15 0.02 15);
|
|
||||||
--color-secondary: oklch(0.70 0.20 300);
|
|
||||||
/* 💜 Electric Purple - playful */
|
|
||||||
--color-secondary-content: oklch(1 0 0);
|
|
||||||
--color-accent: oklch(0.78 0.15 175);
|
|
||||||
/* 🌊 Minty Teal - fresh pop */
|
|
||||||
--color-accent-content: oklch(0.15 0.02 175);
|
|
||||||
--color-neutral: oklch(0.28 0.02 280);
|
|
||||||
--color-neutral-content: oklch(0.92 0.01 280);
|
|
||||||
--color-info: oklch(0.72 0.14 230);
|
--color-info: oklch(0.72 0.14 230);
|
||||||
/* Sky blue */
|
|
||||||
--color-success: oklch(0.75 0.18 155);
|
--color-success: oklch(0.75 0.18 155);
|
||||||
/* Lime green */
|
|
||||||
--color-warning: oklch(0.82 0.16 85);
|
--color-warning: oklch(0.82 0.16 85);
|
||||||
/* Sunny yellow */
|
|
||||||
--color-error: oklch(0.68 0.20 25);
|
--color-error: oklch(0.68 0.20 25);
|
||||||
/* Soft red */
|
|
||||||
--radius-box: 1.25rem;
|
--radius-box: 1.25rem;
|
||||||
--radius-btn: 0.75rem;
|
--radius-btn: 0.75rem;
|
||||||
--radius-badge: 9999px;
|
--radius-badge: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
/* Light theme */
|
||||||
name: "pandaLight";
|
[data-theme="light"] {
|
||||||
/* Fun light theme - bright and cheerful */
|
--color-base-100: oklch(0.99 0.005 240);
|
||||||
--color-base-100: oklch(0.99 0.005 280);
|
--color-base-200: oklch(0.96 0.01 240);
|
||||||
/* Off-white */
|
--color-base-300: oklch(0.92 0.015 240);
|
||||||
--color-base-200: oklch(0.96 0.01 280);
|
--color-base-content: oklch(0.25 0.03 240);
|
||||||
/* Light lavender tint */
|
|
||||||
--color-base-300: oklch(0.92 0.015 280);
|
--color-primary: oklch(0.55 0.25 240);
|
||||||
/* Subtle purple-gray */
|
|
||||||
--color-base-content: oklch(0.25 0.03 280);
|
|
||||||
/* Dark purple text */
|
|
||||||
--color-primary: oklch(0.65 0.20 15);
|
|
||||||
/* 🧡 Coral - punchy */
|
|
||||||
--color-primary-content: oklch(1 0 0);
|
--color-primary-content: oklch(1 0 0);
|
||||||
--color-secondary: oklch(0.58 0.22 300);
|
--color-secondary: oklch(0.60 0.22 200);
|
||||||
/* 💜 Rich Purple */
|
|
||||||
--color-secondary-content: oklch(1 0 0);
|
--color-secondary-content: oklch(1 0 0);
|
||||||
--color-accent: oklch(0.65 0.18 175);
|
--color-accent: oklch(0.50 0.28 260);
|
||||||
/* 🌊 Teal */
|
|
||||||
--color-accent-content: oklch(1 0 0);
|
--color-accent-content: oklch(1 0 0);
|
||||||
--color-neutral: oklch(0.45 0.03 280);
|
--color-neutral: oklch(0.45 0.03 240);
|
||||||
--color-neutral-content: oklch(0.98 0 0);
|
--color-neutral-content: oklch(0.98 0 0);
|
||||||
--color-info: oklch(0.62 0.16 230);
|
--color-info: oklch(0.62 0.16 230);
|
||||||
--color-success: oklch(0.65 0.20 155);
|
--color-success: oklch(0.65 0.20 155);
|
||||||
--color-warning: oklch(0.78 0.18 85);
|
--color-warning: oklch(0.78 0.18 85);
|
||||||
--color-error: oklch(0.58 0.22 25);
|
--color-error: oklch(0.58 0.22 25);
|
||||||
--radius-box: 1.25rem;
|
|
||||||
--radius-btn: 0.75rem;
|
|
||||||
--radius-badge: 9999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
@ -149,6 +123,9 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@ -157,11 +134,379 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
COMPONENT STYLES
|
COMPONENT STYLES - BUTTONS
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|
||||||
|
/* Button base */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline.btn-primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-circle {
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm.btn-circle {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BADGES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-badge);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-primary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: var(--color-secondary-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-accent {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-accent-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: oklch(0.2 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: oklch(0.2 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: oklch(1 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ghost {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline {
|
||||||
|
background: transparent;
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-secondary {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-accent {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-outline.badge-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-lg {
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sm {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
NAVBAR
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-start {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 1;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MENU
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-sm li>a,
|
||||||
|
.menu-sm li>button,
|
||||||
|
.menu-sm li details summary {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li>a,
|
||||||
|
.menu li>button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details summary::after {
|
||||||
|
content: "";
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details[open] summary::after {
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu li details>ul {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
CARD
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
FOOTER
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
THEME TOGGLE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .icon-sun,
|
||||||
|
.theme-toggle .icon-moon {
|
||||||
|
position: absolute;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .icon-sun {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .icon-moon {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .theme-toggle .icon-sun {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .theme-toggle .icon-moon {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
ORIGINAL CUSTOM COMPONENTS
|
||||||
|
======================================== */
|
||||||
|
|
||||||
/* Gradient text effect */
|
/* Gradient text effect */
|
||||||
.gradient-text {
|
.gradient-text {
|
||||||
background: linear-gradient(to right, var(--color-primary), var(--color-secondary), var(--color-accent));
|
background: linear-gradient(to right, var(--color-primary), var(--color-secondary), var(--color-accent));
|
||||||
@ -408,6 +753,138 @@
|
|||||||
border-top: 1px solid oklch(1 0 0 / 0.1);
|
border-top: 1px solid oklch(1 0 0 / 0.1);
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TAILWIND COLOR UTILITIES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.bg-base-100 {
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-base-200 {
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-base-300 {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-base-content {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-base-200 {
|
||||||
|
border-color: var(--color-base-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-base-300 {
|
||||||
|
border-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-primary {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-base-100:hover {
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:bg-base-300:hover {
|
||||||
|
background-color: var(--color-base-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-base-content:hover {
|
||||||
|
color: var(--color-base-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-primary:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-secondary:hover {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-warning:hover {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-success:hover {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-error:hover {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-primary\/50:hover {
|
||||||
|
border-color: oklch(0.75 0.18 15 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-secondary\/50:hover {
|
||||||
|
border-color: oklch(0.70 0.20 300 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-warning\/50:hover {
|
||||||
|
border-color: oklch(0.82 0.16 85 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-success\/50:hover {
|
||||||
|
border-color: oklch(0.75 0.18 155 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-error\/50:hover {
|
||||||
|
border-color: oklch(0.68 0.20 25 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:text-primary {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:text-secondary {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:text-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:text-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:text-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group:hover .group-hover\:bg-primary\/20 {
|
||||||
|
background-color: oklch(0.75 0.18 15 / 0.2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user