Commit Β·
b54d01b
1
Parent(s): 750b124
feat: add invoice creation logic with SQLAlchemy
Browse files- Dockerfile +4 -15
- frontend/css/style.css +262 -0
- frontend/index.html +204 -0
- frontend/js/app.js +206 -0
- frontend/js/data.js +56 -0
- frontend/js/db.js +68 -0
- frontend/js/history.js +100 -0
- frontend/js/rows.js +151 -0
- frontend/js/ui.js +56 -0
- main.py +7 -4
Dockerfile
CHANGED
|
@@ -1,32 +1,21 @@
|
|
| 1 |
FROM python:3.10-slim
|
| 2 |
|
| 3 |
USER root
|
| 4 |
-
# Install system dependencies for Postgres and building extensions
|
| 5 |
RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/*
|
| 6 |
|
| 7 |
-
# Setup user
|
| 8 |
RUN useradd -m -u 1000 user
|
| 9 |
USER user
|
| 10 |
ENV HOME=/home/user \
|
| 11 |
PATH=/home/user/.local/bin:$PATH
|
| 12 |
|
| 13 |
-
# We set the app root
|
| 14 |
WORKDIR $HOME/app
|
| 15 |
|
| 16 |
-
# 1. Copy requirements
|
| 17 |
COPY --chown=user:user requirements.txt .
|
| 18 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 19 |
|
| 20 |
-
# 2. Copy everything
|
| 21 |
-
COPY --chown=user:user . .
|
| 22 |
|
| 23 |
-
# 3.
|
| 24 |
-
# If 'frontend' is next to 'backend', we ensure both are accessible.
|
| 25 |
-
# If you are pushing from the root, use:
|
| 26 |
-
COPY --chown=user:user ../frontend ./frontend
|
| 27 |
-
|
| 28 |
-
# 4. Set the working directory to where main.py lives
|
| 29 |
-
WORKDIR $HOME/app/backend
|
| 30 |
-
|
| 31 |
-
# 5. Run the app
|
| 32 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.10-slim
|
| 2 |
|
| 3 |
USER root
|
|
|
|
| 4 |
RUN apt-get update && apt-get install -y libpq-dev gcc && rm -rf /var/lib/apt/lists/*
|
| 5 |
|
|
|
|
| 6 |
RUN useradd -m -u 1000 user
|
| 7 |
USER user
|
| 8 |
ENV HOME=/home/user \
|
| 9 |
PATH=/home/user/.local/bin:$PATH
|
| 10 |
|
|
|
|
| 11 |
WORKDIR $HOME/app
|
| 12 |
|
| 13 |
+
# 1. Copy requirements and install
|
| 14 |
COPY --chown=user:user requirements.txt .
|
| 15 |
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 16 |
|
| 17 |
+
# 2. Copy everything (including the moved frontend folder)
|
| 18 |
+
COPY --chown=user:user . .
|
| 19 |
|
| 20 |
+
# 3. Run the app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
frontend/css/style.css
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
css/style.css β SmiloCAD Invoice System
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--blue-deep: #1B3A6B;
|
| 9 |
+
--blue-mid: #2563B0;
|
| 10 |
+
--blue-light: #EBF3FF;
|
| 11 |
+
--blue-accent: #3B82F6;
|
| 12 |
+
--teal: #0891B2;
|
| 13 |
+
--teal-light: #E0F7FA;
|
| 14 |
+
--gray-50: #F8FAFC;
|
| 15 |
+
--gray-100: #F1F5F9;
|
| 16 |
+
--gray-200: #E2E8F0;
|
| 17 |
+
--gray-300: #CBD5E1;
|
| 18 |
+
--gray-400: #94A3B8;
|
| 19 |
+
--gray-500: #64748B;
|
| 20 |
+
--gray-700: #334155;
|
| 21 |
+
--gray-900: #0F172A;
|
| 22 |
+
--white: #FFFFFF;
|
| 23 |
+
--green: #10B981;
|
| 24 |
+
--green-light: #D1FAE5;
|
| 25 |
+
--red: #EF4444;
|
| 26 |
+
--red-light: #FEE2E2;
|
| 27 |
+
--amber: #F59E0B;
|
| 28 |
+
--amber-light: #FEF3C7;
|
| 29 |
+
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.05);
|
| 30 |
+
--shadow: 0 4px 16px rgba(27,58,107,0.10);
|
| 31 |
+
--shadow-lg: 0 8px 40px rgba(27,58,107,0.14);
|
| 32 |
+
--radius: 10px;
|
| 33 |
+
--radius-lg: 16px;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
body {
|
| 37 |
+
font-family: 'Nunito', sans-serif;
|
| 38 |
+
background: var(--gray-100);
|
| 39 |
+
color: var(--gray-900);
|
| 40 |
+
min-height: 100vh;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* βββ TOP BAR βββ */
|
| 44 |
+
.topbar {
|
| 45 |
+
background: linear-gradient(135deg, var(--blue-deep) 0%, #1e4d8c 60%, var(--teal) 100%);
|
| 46 |
+
padding: 0 2rem;
|
| 47 |
+
height: 62px;
|
| 48 |
+
display: flex;
|
| 49 |
+
align-items: center;
|
| 50 |
+
justify-content: space-between;
|
| 51 |
+
box-shadow: 0 2px 20px rgba(27,58,107,0.35);
|
| 52 |
+
position: sticky;
|
| 53 |
+
top: 0;
|
| 54 |
+
z-index: 200;
|
| 55 |
+
}
|
| 56 |
+
.topbar-brand { display: flex; align-items: center; gap: 12px; }
|
| 57 |
+
.topbar-icon { width: 38px; height: 38px; background: rgba(255,255,255,0.15); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; border: 1px solid rgba(255,255,255,0.25); }
|
| 58 |
+
.topbar-name { font-family: 'Lora', serif; font-size: 1.12rem; font-weight: 700; color: #fff; letter-spacing: 0.01em; }
|
| 59 |
+
.topbar-sub { font-size: 0.7rem; color: rgba(255,255,255,0.65); letter-spacing: 0.1em; text-transform: uppercase; margin-top: 1px; }
|
| 60 |
+
.topbar-tabs { display: flex; gap: 4px; }
|
| 61 |
+
.topbar-tab { padding: 7px 18px; border-radius: 7px; border: none; background: transparent; color: rgba(255,255,255,0.7); font-family: 'Nunito', sans-serif; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.18s; letter-spacing: 0.02em; }
|
| 62 |
+
.topbar-tab:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
| 63 |
+
.topbar-tab.active { background: rgba(255,255,255,0.22); color: #fff; }
|
| 64 |
+
.topbar-info { display: flex; align-items: center; gap: 8px; font-size: 0.78rem; color: rgba(255,255,255,0.65); }
|
| 65 |
+
.topbar-dot { width: 4px; height: 4px; background: rgba(255,255,255,0.35); border-radius: 50%; }
|
| 66 |
+
|
| 67 |
+
/* βββ LAYOUT βββ */
|
| 68 |
+
.app-body { padding: 2rem; max-width: 1080px; margin: 0 auto; }
|
| 69 |
+
|
| 70 |
+
/* βββ INVOICE CARD βββ */
|
| 71 |
+
.inv-card {
|
| 72 |
+
background: var(--white);
|
| 73 |
+
border-radius: var(--radius-lg);
|
| 74 |
+
box-shadow: var(--shadow-lg);
|
| 75 |
+
overflow: hidden;
|
| 76 |
+
animation: fadeUp 0.35s ease;
|
| 77 |
+
}
|
| 78 |
+
@keyframes fadeUp {
|
| 79 |
+
from { opacity: 0; transform: translateY(14px); }
|
| 80 |
+
to { opacity: 1; transform: translateY(0); }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* βββ INVOICE HEADER BAND βββ */
|
| 84 |
+
.inv-header-band {
|
| 85 |
+
background: linear-gradient(135deg, var(--blue-deep) 0%, #1e4d8c 60%, var(--teal) 100%);
|
| 86 |
+
padding: 2rem 2.5rem;
|
| 87 |
+
display: grid;
|
| 88 |
+
grid-template-columns: 1fr auto;
|
| 89 |
+
gap: 2rem;
|
| 90 |
+
align-items: center;
|
| 91 |
+
}
|
| 92 |
+
.lab-title { font-family: 'Lora', serif; font-size: 1.7rem; font-weight: 700; color: #fff; letter-spacing: 0.01em; line-height: 1.2; }
|
| 93 |
+
.lab-meta { display: flex; align-items: center; gap: 16px; margin-top: 6px; flex-wrap: wrap; }
|
| 94 |
+
.lab-meta-item { display: flex; align-items: center; gap: 5px; font-size: 0.78rem; color: rgba(255,255,255,0.8); }
|
| 95 |
+
.inv-badge-block { text-align: right; }
|
| 96 |
+
.inv-badge-label { font-family: 'Lora', serif; font-size: 2.2rem; font-weight: 700; color: rgba(255,255,255,0.95); text-transform: uppercase; letter-spacing: 0.1em; line-height: 1; }
|
| 97 |
+
.inv-number-pill { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3); border-radius: 20px; padding: 4px 12px; font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9); margin-top: 6px; font-family: monospace; letter-spacing: 0.05em; }
|
| 98 |
+
|
| 99 |
+
/* βββ STATUS BADGES βββ */
|
| 100 |
+
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 6px; }
|
| 101 |
+
.status-pending { background: var(--amber-light); color: #92400E; }
|
| 102 |
+
.status-paid { background: var(--green-light); color: #065F46; }
|
| 103 |
+
.status-partial { background: var(--teal-light); color: #155E75; }
|
| 104 |
+
.status-cancelled{ background: var(--red-light); color: #991B1B; }
|
| 105 |
+
|
| 106 |
+
/* βββ SECTIONS βββ */
|
| 107 |
+
.inv-section { padding: 1.4rem 2.5rem; border-bottom: 1px solid var(--gray-200); }
|
| 108 |
+
.section-title { font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em; color: var(--blue-mid); margin-bottom: 1rem; display: flex; align-items: center; gap: 8px; }
|
| 109 |
+
.section-title::after { content: ''; flex: 1; height: 1.5px; background: linear-gradient(to right, var(--blue-light), transparent); }
|
| 110 |
+
|
| 111 |
+
/* βββ FORM βββ */
|
| 112 |
+
.form-grid { display: grid; gap: 12px; }
|
| 113 |
+
.form-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
| 114 |
+
.form-grid-2 { grid-template-columns: repeat(2, 1fr); }
|
| 115 |
+
.form-grid-4 { grid-template-columns: repeat(4, 1fr); }
|
| 116 |
+
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
| 117 |
+
.form-group label { font-size: 0.71rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--gray-500); }
|
| 118 |
+
.form-control { padding: 9px 12px; border: 1.5px solid var(--gray-200); border-radius: 8px; font-family: 'Nunito', sans-serif; font-size: 0.88rem; color: var(--gray-900); background: var(--white); outline: none; transition: border-color 0.18s, box-shadow 0.18s; width: 100%; }
|
| 119 |
+
.form-control:focus { border-color: var(--blue-accent); box-shadow: 0 0 0 3px rgba(59,130,246,0.1); }
|
| 120 |
+
.form-control::placeholder { color: var(--gray-400); }
|
| 121 |
+
|
| 122 |
+
/* βββ SERVICES TABLE βββ */
|
| 123 |
+
.table-wrap { overflow-x: auto; }
|
| 124 |
+
table.svc-table { width: 100%; border-collapse: collapse; font-size: 0.84rem; }
|
| 125 |
+
.svc-table thead tr { background: var(--blue-deep); }
|
| 126 |
+
.svc-table thead th { padding: 11px 10px; text-align: left; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: rgba(255,255,255,0.85); white-space: nowrap; }
|
| 127 |
+
.svc-table thead th.center { text-align: center; }
|
| 128 |
+
.svc-table thead th.right { text-align: right; }
|
| 129 |
+
.svc-table tbody tr { border-bottom: 1px solid var(--gray-100); transition: background 0.12s; }
|
| 130 |
+
.svc-table tbody tr:nth-child(even) { background: var(--gray-50); }
|
| 131 |
+
.svc-table tbody tr:hover { background: var(--blue-light); }
|
| 132 |
+
.svc-table td { padding: 6px 6px; vertical-align: middle; }
|
| 133 |
+
.svc-table td.sno { text-align: center; font-weight: 700; color: var(--gray-400); font-size: 0.8rem; width: 42px; }
|
| 134 |
+
.svc-table td.total-cell { text-align: right; font-weight: 700; color: var(--blue-deep); font-size: 0.9rem; padding-right: 10px; white-space: nowrap; }
|
| 135 |
+
.svc-table td.action-cell { text-align: center; width: 40px; }
|
| 136 |
+
|
| 137 |
+
.tbl-input { width: 100%; border: 1.5px solid transparent; border-radius: 6px; padding: 7px 9px; font-family: 'Nunito', sans-serif; font-size: 0.84rem; background: transparent; color: var(--gray-900); outline: none; transition: border-color 0.15s, background 0.15s; }
|
| 138 |
+
.tbl-input:focus { border-color: var(--blue-accent); background: var(--white); }
|
| 139 |
+
.tbl-input.right { text-align: right; }
|
| 140 |
+
.tbl-select { width: 100%; border: 1.5px solid transparent; border-radius: 6px; padding: 7px 9px; font-family: 'Nunito', sans-serif; font-size: 0.84rem; background: transparent; color: var(--gray-900); outline: none; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
| 141 |
+
.tbl-select:focus { border-color: var(--blue-accent); background: var(--white); }
|
| 142 |
+
|
| 143 |
+
.btn-del { width: 28px; height: 28px; border: none; border-radius: 7px; background: var(--red-light); color: var(--red); font-size: 15px; cursor: pointer; display: flex; align-items: center; justify-content: center; margin: auto; transition: all 0.15s; font-weight: 700; }
|
| 144 |
+
.btn-del:hover { background: var(--red); color: #fff; transform: scale(1.1); }
|
| 145 |
+
|
| 146 |
+
.add-row-btn { margin-top: 10px; padding: 9px 22px; border: 2px dashed var(--blue-accent); border-radius: 8px; background: transparent; color: var(--blue-mid); font-family: 'Nunito', sans-serif; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all 0.18s; letter-spacing: 0.03em; }
|
| 147 |
+
.add-row-btn:hover { background: var(--blue-light); border-color: var(--blue-deep); color: var(--blue-deep); }
|
| 148 |
+
|
| 149 |
+
/* βββ SUMMARY βββ */
|
| 150 |
+
.summary-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--gray-100); font-size: 0.9rem; }
|
| 151 |
+
.summary-row:last-child { border-bottom: none; }
|
| 152 |
+
.summary-row .label { color: var(--gray-500); font-weight: 600; }
|
| 153 |
+
.summary-row .value { font-weight: 700; color: var(--gray-900); font-size: 0.95rem; }
|
| 154 |
+
.summary-row.net { background: var(--blue-deep); margin: 8px -2.5rem -1.4rem; padding: 16px 2.5rem; border-bottom: none; }
|
| 155 |
+
.summary-row.net .label { color: rgba(255,255,255,0.8); font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
|
| 156 |
+
.summary-row.net .value { color: #fff; font-family: 'Lora', serif; font-size: 1.5rem; }
|
| 157 |
+
.summary-row.received .value { color: var(--green); }
|
| 158 |
+
.summary-row.remaining .value { color: var(--red); }
|
| 159 |
+
|
| 160 |
+
.received-input { width: 160px; text-align: right; padding: 7px 10px; border: 1.5px solid var(--gray-200); border-radius: 7px; font-family: 'Nunito', sans-serif; font-size: 0.9rem; font-weight: 700; color: var(--green); outline: none; transition: border-color 0.18s; }
|
| 161 |
+
.received-input:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(16,185,129,0.1); }
|
| 162 |
+
|
| 163 |
+
/* βββ FOOTER / SIGNATURE βββ */
|
| 164 |
+
.inv-footer { padding: 1.2rem 2.5rem; background: var(--gray-50); border-top: 1px solid var(--gray-200); display: flex; align-items: center; justify-content: space-between; font-size: 0.82rem; color: var(--gray-500); }
|
| 165 |
+
.sig-block { text-align: right; }
|
| 166 |
+
.sig-line { border-top: 1.5px solid var(--gray-400); width: 180px; margin-top: 28px; padding-top: 6px; font-weight: 700; color: var(--gray-700); }
|
| 167 |
+
|
| 168 |
+
/* βββ ACTION BAR βββ */
|
| 169 |
+
.action-bar { padding: 1.2rem 2.5rem; background: var(--white); border-top: 1px solid var(--gray-200); display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
| 170 |
+
.btn { padding: 10px 22px; border-radius: 9px; border: none; font-family: 'Nunito', sans-serif; font-size: 0.86rem; font-weight: 700; cursor: pointer; transition: all 0.18s; display: flex; align-items: center; gap: 7px; letter-spacing: 0.02em; white-space: nowrap; }
|
| 171 |
+
.btn-save { background: var(--blue-deep); color: #fff; }
|
| 172 |
+
.btn-save:hover { background: #162f5a; transform: translateY(-1px); box-shadow: var(--shadow); }
|
| 173 |
+
.btn-pdf { background: var(--teal); color: #fff; }
|
| 174 |
+
.btn-pdf:hover { background: #0e7490; transform: translateY(-1px); box-shadow: var(--shadow); }
|
| 175 |
+
.btn-print { background: var(--gray-700); color: #fff; }
|
| 176 |
+
.btn-print:hover { background: var(--gray-900); transform: translateY(-1px); box-shadow: var(--shadow); }
|
| 177 |
+
.btn-excel { background: var(--green); color: #fff; }
|
| 178 |
+
.btn-excel:hover { background: #059669; transform: translateY(-1px); box-shadow: var(--shadow); }
|
| 179 |
+
.btn-clear { background: var(--amber-light); color: #92400E; border: 1.5px solid #FDE68A; }
|
| 180 |
+
.btn-clear:hover { background: #FDE68A; }
|
| 181 |
+
.btn-new { background: var(--blue-light); color: var(--blue-deep); border: 1.5px solid #BFDBFE; }
|
| 182 |
+
.btn-new:hover { background: #BFDBFE; }
|
| 183 |
+
.btn-spacer { flex: 1; }
|
| 184 |
+
|
| 185 |
+
/* βββ HISTORY PAGE βββ */
|
| 186 |
+
.page-heading { display: flex; align-items: baseline; gap: 12px; margin-bottom: 1.5rem; }
|
| 187 |
+
.page-heading h2 { font-family: 'Lora', serif; font-size: 1.5rem; font-weight: 700; color: var(--blue-deep); }
|
| 188 |
+
.count-pill { background: var(--blue-light); color: var(--blue-mid); padding: 3px 12px; border-radius: 20px; font-size: 0.78rem; font-weight: 700; border: 1px solid #BFDBFE; }
|
| 189 |
+
.search-wrap { margin-bottom: 1.2rem; position: relative; }
|
| 190 |
+
.search-wrap input { width: 100%; padding: 10px 14px 10px 40px; border: 1.5px solid var(--gray-200); border-radius: 9px; font-family: 'Nunito', sans-serif; font-size: 0.9rem; outline: none; background: var(--white); transition: border-color 0.18s; }
|
| 191 |
+
.search-wrap input:focus { border-color: var(--blue-accent); }
|
| 192 |
+
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--gray-400); font-size: 16px; }
|
| 193 |
+
|
| 194 |
+
.hist-list { display: flex; flex-direction: column; gap: 10px; }
|
| 195 |
+
.hist-item { background: var(--white); border-radius: var(--radius); padding: 1rem 1.4rem; box-shadow: var(--shadow-sm); display: grid; grid-template-columns: auto 1fr 1fr auto auto; gap: 1rem; align-items: center; border-left: 4px solid transparent; transition: box-shadow 0.18s, border-color 0.18s; }
|
| 196 |
+
.hist-item:hover { box-shadow: var(--shadow); border-left-color: var(--blue-accent); }
|
| 197 |
+
.hist-inv-no { font-size: 0.78rem; font-weight: 800; font-family: monospace; color: var(--blue-mid); background: var(--blue-light); padding: 4px 10px; border-radius: 6px; border: 1px solid #BFDBFE; white-space: nowrap; }
|
| 198 |
+
.hist-client-name { font-weight: 700; font-size: 0.95rem; }
|
| 199 |
+
.hist-date { font-size: 0.78rem; color: var(--gray-400); }
|
| 200 |
+
.hist-detail { font-size: 0.82rem; color: var(--gray-500); }
|
| 201 |
+
.hist-detail span { font-weight: 600; color: var(--gray-700); }
|
| 202 |
+
.hist-amount { font-family: 'Lora', serif; font-size: 1.05rem; font-weight: 700; color: var(--blue-deep); text-align: right; }
|
| 203 |
+
.hist-actions { display: flex; gap: 6px; }
|
| 204 |
+
.btn-xs { padding: 6px 14px; border-radius: 6px; border: none; font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all 0.15s; }
|
| 205 |
+
.btn-xs-blue { background: var(--blue-deep); color: #fff; }
|
| 206 |
+
.btn-xs-blue:hover { background: #162f5a; }
|
| 207 |
+
.btn-xs-red { background: var(--red-light); color: var(--red); }
|
| 208 |
+
.btn-xs-red:hover { background: var(--red); color: #fff; }
|
| 209 |
+
|
| 210 |
+
.empty-state { text-align: center; padding: 5rem 2rem; color: var(--gray-400); }
|
| 211 |
+
.empty-state .ico { font-size: 3.5rem; margin-bottom: 1rem; }
|
| 212 |
+
.empty-state h3 { font-family: 'Lora', serif; color: var(--gray-700); font-size: 1.3rem; margin-bottom: 6px; }
|
| 213 |
+
|
| 214 |
+
/* βββ TOAST βββ */
|
| 215 |
+
#toast {
|
| 216 |
+
position: fixed;
|
| 217 |
+
bottom: 2rem; right: 2rem;
|
| 218 |
+
background: var(--blue-deep);
|
| 219 |
+
color: #fff;
|
| 220 |
+
padding: 12px 20px;
|
| 221 |
+
border-radius: 10px;
|
| 222 |
+
font-size: 0.87rem;
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
box-shadow: var(--shadow-lg);
|
| 225 |
+
display: flex;
|
| 226 |
+
align-items: center;
|
| 227 |
+
gap: 8px;
|
| 228 |
+
opacity: 0;
|
| 229 |
+
transform: translateY(16px);
|
| 230 |
+
transition: all 0.3s cubic-bezier(0.34,1.56,0.64,1);
|
| 231 |
+
z-index: 9999;
|
| 232 |
+
pointer-events: none;
|
| 233 |
+
}
|
| 234 |
+
#toast.show { opacity: 1; transform: translateY(0); }
|
| 235 |
+
#toast.success { border-left: 4px solid var(--green); }
|
| 236 |
+
#toast.error { border-left: 4px solid var(--red); }
|
| 237 |
+
#toast.info { border-left: 4px solid var(--teal); }
|
| 238 |
+
|
| 239 |
+
/* βββ PRINT βββ */
|
| 240 |
+
@media print {
|
| 241 |
+
body { background: #fff !important; }
|
| 242 |
+
.topbar, .action-bar, .btn-del, .add-row-btn, #toast, #page-history { display: none !important; }
|
| 243 |
+
#page-invoice { display: block !important; }
|
| 244 |
+
.app-body { padding: 0 !important; max-width: none !important; }
|
| 245 |
+
.inv-card { box-shadow: none !important; border: 1px solid #ddd; }
|
| 246 |
+
.inv-header-band, .svc-table thead tr, .summary-row.net { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
| 247 |
+
.tbl-input, .tbl-select, .form-control, .received-input { border: none !important; background: transparent !important; }
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
/* βββ RESPONSIVE βββ */
|
| 251 |
+
@media (max-width: 768px) {
|
| 252 |
+
.app-body { padding: 1rem; }
|
| 253 |
+
.inv-header-band { grid-template-columns: 1fr; }
|
| 254 |
+
.inv-badge-block { text-align: left; }
|
| 255 |
+
.form-grid-3, .form-grid-4 { grid-template-columns: 1fr 1fr; }
|
| 256 |
+
.form-grid-2 { grid-template-columns: 1fr; }
|
| 257 |
+
.topbar-info { display: none; }
|
| 258 |
+
.hist-item { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; }
|
| 259 |
+
.inv-section { padding: 1.2rem 1.2rem; }
|
| 260 |
+
.inv-header-band { padding: 1.5rem 1.2rem; }
|
| 261 |
+
.action-bar { padding: 1rem 1.2rem; }
|
| 262 |
+
}
|
frontend/index.html
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
| 6 |
+
<title>SmiloCAD Dental Laboratory β Invoice System</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@300;400;500;600;700;800&family=Lora:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
| 8 |
+
<link rel="stylesheet" href="css/style.css"/>
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
|
| 12 |
+
<!-- TOP NAV BAR -->
|
| 13 |
+
<div class="topbar">
|
| 14 |
+
<div class="topbar-brand">
|
| 15 |
+
<div class="topbar-icon">π¦·</div>
|
| 16 |
+
<div>
|
| 17 |
+
<div class="topbar-name">SmiloCAD Dental Laboratory</div>
|
| 18 |
+
<div class="topbar-sub">Invoice Management System</div>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="topbar-tabs">
|
| 22 |
+
<button class="topbar-tab active" id="tab-invoice" onclick="App.showPage('invoice')">β New Invoice</button>
|
| 23 |
+
<button class="topbar-tab" id="tab-history" onclick="App.showPage('history')">π History</button>
|
| 24 |
+
</div>
|
| 25 |
+
<div class="topbar-info">
|
| 26 |
+
<span>π 0328-9577771</span>
|
| 27 |
+
<span class="topbar-dot"></span>
|
| 28 |
+
<span>Al Anayat Plaza, G11 Markaz Islamabad</span>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<!-- βββ INVOICE PAGE βββ -->
|
| 33 |
+
<div class="app-body" id="page-invoice">
|
| 34 |
+
<div class="inv-card">
|
| 35 |
+
|
| 36 |
+
<!-- Header Band -->
|
| 37 |
+
<div class="inv-header-band">
|
| 38 |
+
<div>
|
| 39 |
+
<div class="lab-title">SmiloCAD Dental Laboratory</div>
|
| 40 |
+
<div class="lab-meta">
|
| 41 |
+
<div class="lab-meta-item">π 0328-9577771</div>
|
| 42 |
+
<div class="lab-meta-item">π Al Anayat Plaza, G11 Markaz Islamabad</div>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="inv-badge-block">
|
| 46 |
+
<div class="inv-badge-label">Invoice</div>
|
| 47 |
+
<div class="inv-number-pill" id="display-inv-number">#INV-0001</div>
|
| 48 |
+
<div><span class="status-badge status-pending" id="display-status">Pending</span></div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Invoice Details -->
|
| 53 |
+
<div class="inv-section">
|
| 54 |
+
<div class="section-title">Invoice Details</div>
|
| 55 |
+
<div class="form-grid form-grid-3">
|
| 56 |
+
<div class="form-group">
|
| 57 |
+
<label>Invoice No.</label>
|
| 58 |
+
<input class="form-control" id="inv-number" placeholder="INV-0001"/>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<label>Date</label>
|
| 62 |
+
<input type="date" class="form-control" id="inv-date"/>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="form-group">
|
| 65 |
+
<label>Status</label>
|
| 66 |
+
<select class="form-control" id="inv-status">
|
| 67 |
+
<option>Pending</option>
|
| 68 |
+
<option>Paid</option>
|
| 69 |
+
<option>Partial</option>
|
| 70 |
+
<option>Cancelled</option>
|
| 71 |
+
</select>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<!-- Doctor & Patient -->
|
| 77 |
+
<div class="inv-section">
|
| 78 |
+
<div class="section-title">Doctor & Patient Information</div>
|
| 79 |
+
<div class="form-grid form-grid-2" style="margin-bottom:12px">
|
| 80 |
+
<div class="form-group">
|
| 81 |
+
<label>Doctor Name</label>
|
| 82 |
+
<input class="form-control" id="doctor-name" placeholder="Enter doctor name"/>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="form-group">
|
| 85 |
+
<label>Clinic Name</label>
|
| 86 |
+
<input class="form-control" id="clinic" placeholder="e.g. Fit Dental Care"/>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<div class="form-grid form-grid-2">
|
| 90 |
+
<div class="form-group">
|
| 91 |
+
<label>Patient Name</label>
|
| 92 |
+
<input class="form-control" id="patient" placeholder="Patient full name"/>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="form-group">
|
| 95 |
+
<label>Shade</label>
|
| 96 |
+
<input class="form-control" id="shade" placeholder="Enter shade"/>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<!-- Services Table -->
|
| 102 |
+
<div class="inv-section">
|
| 103 |
+
<div class="section-title">Services / Work Items</div>
|
| 104 |
+
<div class="table-wrap">
|
| 105 |
+
<table class="svc-table">
|
| 106 |
+
<thead>
|
| 107 |
+
<tr>
|
| 108 |
+
<th class="center">#</th>
|
| 109 |
+
<th style="min-width:220px">Description</th>
|
| 110 |
+
<th style="min-width:90px" class="center">Quantity</th>
|
| 111 |
+
<th style="min-width:110px" class="right">Price/Unit (PKR)</th>
|
| 112 |
+
<th style="min-width:120px" class="right">Total (PKR)</th>
|
| 113 |
+
<th class="center">β</th>
|
| 114 |
+
</tr>
|
| 115 |
+
</thead>
|
| 116 |
+
<tbody id="rows-body">
|
| 117 |
+
<!-- Rows injected by JS -->
|
| 118 |
+
</tbody>
|
| 119 |
+
</table>
|
| 120 |
+
</div>
|
| 121 |
+
<button class="add-row-btn" onclick="Rows.add()">+ Add Row</button>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<!-- Summary -->
|
| 125 |
+
<div class="inv-section">
|
| 126 |
+
<div class="section-title">Summary</div>
|
| 127 |
+
<div style="max-width:380px;margin-left:auto">
|
| 128 |
+
<div class="summary-row">
|
| 129 |
+
<span class="label">Subtotal</span>
|
| 130 |
+
<span class="value" id="summary-subtotal">PKR 0</span>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="summary-row received">
|
| 133 |
+
<span class="label">Received Amount</span>
|
| 134 |
+
<input class="received-input" type="number" min="0" placeholder="0" id="received-input"/>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="summary-row remaining">
|
| 137 |
+
<span class="label">Remaining Balance</span>
|
| 138 |
+
<span class="value" id="summary-remaining">PKR 0</span>
|
| 139 |
+
</div>
|
| 140 |
+
<div class="summary-row net">
|
| 141 |
+
<span class="label">Total Amount</span>
|
| 142 |
+
<span class="value" id="summary-total">PKR 0</span>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<!-- Notes -->
|
| 148 |
+
<div class="inv-section" style="border-bottom:none">
|
| 149 |
+
<div class="section-title">Notes / Terms</div>
|
| 150 |
+
<textarea class="form-control" id="notes" rows="2"
|
| 151 |
+
style="resize:vertical;min-height:60px"
|
| 152 |
+
placeholder="Payment terms, delivery schedule, special instructionsβ¦"></textarea>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
<!-- Footer -->
|
| 156 |
+
<div class="inv-footer">
|
| 157 |
+
<div>Thank you for your trust in <strong>SmiloCAD Dental Laboratory</strong></div>
|
| 158 |
+
<div class="sig-block">
|
| 159 |
+
<div class="sig-line">Lab Technician: Dt. Sajid</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<!-- Action Bar -->
|
| 164 |
+
<div class="action-bar">
|
| 165 |
+
<button class="btn btn-save" onclick="App.save()">πΎ Save</button>
|
| 166 |
+
<button class="btn btn-pdf" onclick="App.downloadPDF()">β¬οΈ Download PDF</button>
|
| 167 |
+
<button class="btn btn-print" onclick="App.print()">π¨οΈ Print</button>
|
| 168 |
+
<button class="btn btn-excel" onclick="App.exportExcel()">π Export Excel</button>
|
| 169 |
+
<div class="btn-spacer"></div>
|
| 170 |
+
<button class="btn btn-new" onclick="App.newInvoice()">π New Invoice</button>
|
| 171 |
+
<button class="btn btn-clear" onclick="App.clear()">ποΈ Clear</button>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<!-- βββ HISTORY PAGE βββ -->
|
| 178 |
+
<div class="app-body" id="page-history" style="display:none">
|
| 179 |
+
<div class="page-heading">
|
| 180 |
+
<h2>Invoice History</h2>
|
| 181 |
+
<span class="count-pill" id="history-count">0 invoices</span>
|
| 182 |
+
</div>
|
| 183 |
+
<div class="search-wrap">
|
| 184 |
+
<span class="search-icon">π</span>
|
| 185 |
+
<input id="search-input" placeholder="Search by doctor, clinic, patient, invoice no., or statusβ¦"
|
| 186 |
+
oninput="History.filter()"/>
|
| 187 |
+
</div>
|
| 188 |
+
<div class="hist-list" id="hist-list">
|
| 189 |
+
<!-- Injected by JS -->
|
| 190 |
+
</div>
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
<!-- Toast -->
|
| 194 |
+
<div id="toast"></div>
|
| 195 |
+
|
| 196 |
+
<!-- JS Files (load in order) -->
|
| 197 |
+
<script src="js/data.js"></script>
|
| 198 |
+
<script src="js/db.js"></script>
|
| 199 |
+
<script src="js/ui.js"></script>
|
| 200 |
+
<script src="js/rows.js"></script>
|
| 201 |
+
<script src="js/history.js"></script>
|
| 202 |
+
<script src="js/app.js"></script>
|
| 203 |
+
</body>
|
| 204 |
+
</html>
|
frontend/js/app.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/app.js β Main application controller
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
var App = (function() {
|
| 6 |
+
|
| 7 |
+
var _currentId = null; // id of the invoice being edited (null = new)
|
| 8 |
+
|
| 9 |
+
/* ββ Collect all form values into one plain object ββ */
|
| 10 |
+
function _collect() {
|
| 11 |
+
var subtotal = Rows.subtotal();
|
| 12 |
+
var received = parseFloat(document.getElementById("received-input").value) || 0;
|
| 13 |
+
|
| 14 |
+
return {
|
| 15 |
+
invNumber: document.getElementById("inv-number").value,
|
| 16 |
+
invDate: document.getElementById("inv-date").value,
|
| 17 |
+
doctor: document.getElementById("doctor-name").value,
|
| 18 |
+
clinic: document.getElementById("clinic").value,
|
| 19 |
+
patient: document.getElementById("patient").value,
|
| 20 |
+
shade: document.getElementById("shade").value,
|
| 21 |
+
status: document.getElementById("inv-status").value,
|
| 22 |
+
rows: Rows.collect(),
|
| 23 |
+
subtotal: subtotal,
|
| 24 |
+
received: received,
|
| 25 |
+
remaining: subtotal - received,
|
| 26 |
+
notes: document.getElementById("notes").value,
|
| 27 |
+
savedAt: new Date().toISOString(),
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* ββ Populate all form fields from a saved invoice object ββ */
|
| 32 |
+
function _populate(inv) {
|
| 33 |
+
document.getElementById("inv-number").value = inv.invNumber || "";
|
| 34 |
+
document.getElementById("inv-date").value = inv.invDate || "";
|
| 35 |
+
document.getElementById("inv-status").value = inv.status || "Pending";
|
| 36 |
+
document.getElementById("doctor-name").value = inv.doctor || "";
|
| 37 |
+
document.getElementById("clinic").value = inv.clinic || "";
|
| 38 |
+
document.getElementById("patient").value = inv.patient || "";
|
| 39 |
+
document.getElementById("shade").value = inv.shade || "";
|
| 40 |
+
document.getElementById("notes").value = inv.notes || "";
|
| 41 |
+
document.getElementById("received-input").value = inv.received != null ? inv.received : "";
|
| 42 |
+
|
| 43 |
+
Rows.load(inv.rows || []);
|
| 44 |
+
updateHeaderBadge();
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ββ Generate the next invoice number from DB count ββ */
|
| 48 |
+
async function _nextInvNumber() {
|
| 49 |
+
var all = await dbAll();
|
| 50 |
+
var next = String(all.length + 1).padStart(4, "0");
|
| 51 |
+
return "INV-" + next;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* ββ Reset form to a blank new invoice ββ */
|
| 55 |
+
async function _resetForm() {
|
| 56 |
+
_currentId = null;
|
| 57 |
+
var num = await _nextInvNumber();
|
| 58 |
+
document.getElementById("inv-number").value = num;
|
| 59 |
+
document.getElementById("inv-date").value = todayStr();
|
| 60 |
+
document.getElementById("inv-status").value = "Pending";
|
| 61 |
+
document.getElementById("doctor-name").value = "";
|
| 62 |
+
document.getElementById("clinic").value = "";
|
| 63 |
+
document.getElementById("patient").value = "";
|
| 64 |
+
document.getElementById("shade").value = "";
|
| 65 |
+
document.getElementById("notes").value = "";
|
| 66 |
+
document.getElementById("received-input").value = "";
|
| 67 |
+
Rows.reset();
|
| 68 |
+
updateHeaderBadge();
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* ββββββββββββββββββββββββββββββββββββββββ
|
| 72 |
+
PUBLIC METHODS
|
| 73 |
+
ββββββββββββββββββββββββββββββββββββββββ */
|
| 74 |
+
|
| 75 |
+
/* Switch visible page */
|
| 76 |
+
function showPage(page) {
|
| 77 |
+
switchPage(page);
|
| 78 |
+
if (page === "history") History.load();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* Save current invoice */
|
| 82 |
+
async function save() {
|
| 83 |
+
var data = _collect();
|
| 84 |
+
if (_currentId) data.id = _currentId;
|
| 85 |
+
var id = await dbSave(data);
|
| 86 |
+
_currentId = data.id || id;
|
| 87 |
+
showToast("β
Invoice saved successfully!", "success");
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* New blank invoice */
|
| 91 |
+
async function newInvoice() {
|
| 92 |
+
await _resetForm();
|
| 93 |
+
showPage("invoice");
|
| 94 |
+
showToast("π New invoice ready", "info");
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/* Clear the current form fields (keep invoice number & date) */
|
| 98 |
+
function clear() {
|
| 99 |
+
if (!confirm("Clear the current form?")) return;
|
| 100 |
+
document.getElementById("doctor-name").value = "";
|
| 101 |
+
document.getElementById("clinic").value = "";
|
| 102 |
+
document.getElementById("patient").value = "";
|
| 103 |
+
document.getElementById("shade").value = "";
|
| 104 |
+
document.getElementById("inv-status").value = "Pending";
|
| 105 |
+
document.getElementById("notes").value = "";
|
| 106 |
+
document.getElementById("received-input").value = "";
|
| 107 |
+
Rows.reset();
|
| 108 |
+
updateHeaderBadge();
|
| 109 |
+
showToast("ποΈ Form cleared", "info");
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Print the invoice */
|
| 113 |
+
function print() {
|
| 114 |
+
showPage("invoice");
|
| 115 |
+
setTimeout(function() { window.print(); }, 200);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* Download as PDF (uses browser print dialog) */
|
| 119 |
+
function downloadPDF() {
|
| 120 |
+
showToast("π Choose 'Save as PDF' in the print dialog", "info");
|
| 121 |
+
setTimeout(function() { window.print(); }, 400);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* Export as CSV (opens in Excel) */
|
| 125 |
+
function exportExcel() {
|
| 126 |
+
var data = _collect();
|
| 127 |
+
var lines = [
|
| 128 |
+
["SmiloCAD Dental Laboratory"],
|
| 129 |
+
[LAB.address],
|
| 130 |
+
["Phone:", LAB.phone],
|
| 131 |
+
[],
|
| 132 |
+
["Invoice No.", data.invNumber, "Date:", data.invDate],
|
| 133 |
+
["Doctor:", data.doctor, "Clinic:", data.clinic],
|
| 134 |
+
["Patient:", data.patient, "Shade:", data.shade],
|
| 135 |
+
["Status:", data.status],
|
| 136 |
+
[],
|
| 137 |
+
["S.No", "Description", "Quantity", "Price/Unit", "Total"],
|
| 138 |
+
];
|
| 139 |
+
|
| 140 |
+
data.rows.filter(function(r) { return r.desc || r.qty || r.price; })
|
| 141 |
+
.forEach(function(r, i) {
|
| 142 |
+
lines.push([i + 1, r.desc, r.qty, r.price, r.total]);
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
lines = lines.concat([
|
| 146 |
+
[],
|
| 147 |
+
["", "", "", "Total:", data.subtotal],
|
| 148 |
+
["", "", "", "Received:", data.received],
|
| 149 |
+
["", "", "", "Remaining:", data.remaining],
|
| 150 |
+
[],
|
| 151 |
+
["Notes:", data.notes],
|
| 152 |
+
["Lab Technician:", LAB.tech],
|
| 153 |
+
]);
|
| 154 |
+
|
| 155 |
+
var csv = lines.map(function(row) {
|
| 156 |
+
return row.map(function(cell) {
|
| 157 |
+
return '"' + String(cell != null ? cell : "").replace(/"/g, '""') + '"';
|
| 158 |
+
}).join(",");
|
| 159 |
+
}).join("\n");
|
| 160 |
+
|
| 161 |
+
var a = document.createElement("a");
|
| 162 |
+
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
| 163 |
+
a.download = data.invNumber + ".csv";
|
| 164 |
+
a.click();
|
| 165 |
+
showToast("π Exported as CSV (open in Excel)", "success");
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* Load a saved invoice into the form for editing */
|
| 169 |
+
async function loadEdit(id) {
|
| 170 |
+
/* Try the History cache first, then fall back to a fresh DB read */
|
| 171 |
+
var inv = History.getById(id);
|
| 172 |
+
if (!inv) {
|
| 173 |
+
var all = await dbAll();
|
| 174 |
+
inv = all.find(function(r) { return r.id === id; });
|
| 175 |
+
}
|
| 176 |
+
if (!inv) { showToast("β Invoice not found", "error"); return; }
|
| 177 |
+
|
| 178 |
+
_currentId = inv.id;
|
| 179 |
+
_populate(inv);
|
| 180 |
+
showPage("invoice");
|
| 181 |
+
showToast("βοΈ Invoice loaded for editing", "info");
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* ββ Boot: open DB, set defaults, render two blank rows ββ */
|
| 185 |
+
document.addEventListener("DOMContentLoaded", async function() {
|
| 186 |
+
await dbOpen();
|
| 187 |
+
var num = await _nextInvNumber();
|
| 188 |
+
document.getElementById("inv-number").value = num;
|
| 189 |
+
document.getElementById("inv-date").value = todayStr();
|
| 190 |
+
updateHeaderBadge();
|
| 191 |
+
Rows.reset();
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
/* Public API */
|
| 195 |
+
return {
|
| 196 |
+
showPage: showPage,
|
| 197 |
+
save: save,
|
| 198 |
+
newInvoice: newInvoice,
|
| 199 |
+
clear: clear,
|
| 200 |
+
print: print,
|
| 201 |
+
downloadPDF: downloadPDF,
|
| 202 |
+
exportExcel: exportExcel,
|
| 203 |
+
loadEdit: loadEdit,
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
})();
|
frontend/js/data.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/data.js β All static data constants
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
const LAB = {
|
| 6 |
+
name: "SmiloCAD Dental Laboratory",
|
| 7 |
+
phone: "0328-9577771",
|
| 8 |
+
address: "Al Anayat Plaza, G11 Markaz Islamabad",
|
| 9 |
+
tech: "Dt. Sajid",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const SERVICES = [
|
| 13 |
+
"Zirconium Crown",
|
| 14 |
+
"Veneers",
|
| 15 |
+
"PFM Crown",
|
| 16 |
+
"PFM Design",
|
| 17 |
+
"PFM 3D",
|
| 18 |
+
"Zirconium Bridge + I Bar",
|
| 19 |
+
"Zirconium Bridge",
|
| 20 |
+
"Full Zirconium",
|
| 21 |
+
"E-Max Crown",
|
| 22 |
+
"E-Max Veneer",
|
| 23 |
+
"Metal Crown",
|
| 24 |
+
"Acrylic Crown",
|
| 25 |
+
"Implant Crown",
|
| 26 |
+
"Implant Abutment",
|
| 27 |
+
"Complete Denture",
|
| 28 |
+
"Partial Denture (RPD)",
|
| 29 |
+
"Immediate Denture",
|
| 30 |
+
"Night Guard",
|
| 31 |
+
"Retainer",
|
| 32 |
+
"Maryland Bridge",
|
| 33 |
+
"Inlay",
|
| 34 |
+
"Onlay",
|
| 35 |
+
"Post & Core",
|
| 36 |
+
"Study Model",
|
| 37 |
+
"Occlusal Splint",
|
| 38 |
+
"Other",
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
const DOCTORS = [
|
| 42 |
+
"Dr. Haroon Shah",
|
| 43 |
+
"Dr. Ahmad Raza",
|
| 44 |
+
"Dr. Sara Khan",
|
| 45 |
+
"Dr. Imran Ali",
|
| 46 |
+
"Dr. Fatima Malik",
|
| 47 |
+
"Other",
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
const SHADES = [
|
| 51 |
+
"β","A1","A2","A3","A3.5","A4",
|
| 52 |
+
"B1","B2","B3","B4",
|
| 53 |
+
"C1","C2","C3","C4",
|
| 54 |
+
"D2","D3","D4",
|
| 55 |
+
"BL1","BL2","BL3","BL4","Custom",
|
| 56 |
+
];
|
frontend/js/db.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/db.js β IndexedDB database helpers
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
const DB_NAME = "SmiloCAD_DB";
|
| 6 |
+
const DB_STORE = "invoices";
|
| 7 |
+
const DB_VER = 1;
|
| 8 |
+
|
| 9 |
+
let _db = null; // holds the open IDBDatabase instance
|
| 10 |
+
|
| 11 |
+
/* Open (or create) the database */
|
| 12 |
+
function dbOpen() {
|
| 13 |
+
return new Promise(function(resolve, reject) {
|
| 14 |
+
const request = indexedDB.open(DB_NAME, DB_VER);
|
| 15 |
+
|
| 16 |
+
request.onupgradeneeded = function(e) {
|
| 17 |
+
const db = e.target.result;
|
| 18 |
+
if (!db.objectStoreNames.contains(DB_STORE)) {
|
| 19 |
+
db.createObjectStore(DB_STORE, { keyPath: "id", autoIncrement: true });
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
request.onsuccess = function(e) {
|
| 24 |
+
_db = e.target.result;
|
| 25 |
+
resolve(_db);
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
request.onerror = function(e) {
|
| 29 |
+
reject(e);
|
| 30 |
+
};
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Save (insert or update) one invoice record */
|
| 35 |
+
function dbSave(data) {
|
| 36 |
+
return new Promise(function(resolve, reject) {
|
| 37 |
+
const tx = _db.transaction(DB_STORE, "readwrite");
|
| 38 |
+
const store = tx.objectStore(DB_STORE);
|
| 39 |
+
const request = data.id ? store.put(data) : store.add(data);
|
| 40 |
+
|
| 41 |
+
request.onsuccess = function(e) { resolve(e.target.result); };
|
| 42 |
+
request.onerror = reject;
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Load all invoice records */
|
| 47 |
+
function dbAll() {
|
| 48 |
+
return new Promise(function(resolve, reject) {
|
| 49 |
+
const request = _db.transaction(DB_STORE, "readonly")
|
| 50 |
+
.objectStore(DB_STORE)
|
| 51 |
+
.getAll();
|
| 52 |
+
|
| 53 |
+
request.onsuccess = function(e) { resolve(e.target.result); };
|
| 54 |
+
request.onerror = reject;
|
| 55 |
+
});
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/* Delete one invoice by id */
|
| 59 |
+
function dbDelete(id) {
|
| 60 |
+
return new Promise(function(resolve, reject) {
|
| 61 |
+
const request = _db.transaction(DB_STORE, "readwrite")
|
| 62 |
+
.objectStore(DB_STORE)
|
| 63 |
+
.delete(id);
|
| 64 |
+
|
| 65 |
+
request.onsuccess = resolve;
|
| 66 |
+
request.onerror = reject;
|
| 67 |
+
});
|
| 68 |
+
}
|
frontend/js/history.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/history.js β Invoice history page
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
var History = (function() {
|
| 6 |
+
|
| 7 |
+
var _allInvoices = []; // cache of loaded invoices
|
| 8 |
+
|
| 9 |
+
/* Format PKR */
|
| 10 |
+
function _fmt(n) { return fmt(n); }
|
| 11 |
+
|
| 12 |
+
/* Build HTML for a single history card */
|
| 13 |
+
function _buildCard(inv) {
|
| 14 |
+
var itemCount = (inv.rows || []).filter(function(r) { return r.desc; }).length;
|
| 15 |
+
return [
|
| 16 |
+
'<div class="hist-item">',
|
| 17 |
+
'<div class="hist-inv-no">' + (inv.invNumber || "β") + '</div>',
|
| 18 |
+
'<div>',
|
| 19 |
+
'<div class="hist-client-name">' + (inv.doctor || "Unknown Doctor") + '</div>',
|
| 20 |
+
'<div class="hist-date">π
' + (inv.invDate || "β") + ' Β· ' + (inv.clinic || "β") + '</div>',
|
| 21 |
+
'</div>',
|
| 22 |
+
'<div>',
|
| 23 |
+
'<div class="hist-detail">Patient: <span>' + (inv.patient || "β") + '</span></div>',
|
| 24 |
+
'<div class="hist-detail">Items: <span>' + itemCount + '</span> Β· Status: <span>' + (inv.status || "β") + '</span></div>',
|
| 25 |
+
'</div>',
|
| 26 |
+
'<div>',
|
| 27 |
+
'<div class="hist-amount">' + _fmt(inv.subtotal || 0) + '</div>',
|
| 28 |
+
'<div style="font-size:0.75rem;color:var(--green);text-align:right;margin-top:2px">Rcvd: ' + _fmt(inv.received || 0) + '</div>',
|
| 29 |
+
'</div>',
|
| 30 |
+
'<div class="hist-actions">',
|
| 31 |
+
'<button class="btn-xs btn-xs-blue" onclick="App.loadEdit(' + inv.id + ')">βοΈ Load</button>',
|
| 32 |
+
'<button class="btn-xs btn-xs-red" onclick="History.deleteInvoice(' + inv.id + ')">ποΈ</button>',
|
| 33 |
+
'</div>',
|
| 34 |
+
'</div>',
|
| 35 |
+
].join("");
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Render the list β optionally filtered by a search term */
|
| 39 |
+
function _render(list) {
|
| 40 |
+
var container = document.getElementById("hist-list");
|
| 41 |
+
var count = document.getElementById("history-count");
|
| 42 |
+
|
| 43 |
+
count.textContent = list.length + " invoice" + (list.length !== 1 ? "s" : "");
|
| 44 |
+
|
| 45 |
+
if (list.length === 0) {
|
| 46 |
+
var q = document.getElementById("search-input").value;
|
| 47 |
+
container.innerHTML = [
|
| 48 |
+
'<div class="empty-state">',
|
| 49 |
+
'<div class="ico">π</div>',
|
| 50 |
+
'<h3>' + (q ? "No matching invoices" : "No invoices yet") + '</h3>',
|
| 51 |
+
'<p>' + (q ? "Try a different search term." : "Create your first invoice to see it here.") + '</p>',
|
| 52 |
+
'</div>',
|
| 53 |
+
].join("");
|
| 54 |
+
} else {
|
| 55 |
+
container.innerHTML = list.map(_buildCard).join("");
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Load all invoices from DB and render */
|
| 60 |
+
function load() {
|
| 61 |
+
dbAll().then(function(all) {
|
| 62 |
+
_allInvoices = all.reverse(); // newest first
|
| 63 |
+
filter();
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Filter the cached list by search term and re-render */
|
| 68 |
+
function filter() {
|
| 69 |
+
var q = (document.getElementById("search-input").value || "").toLowerCase();
|
| 70 |
+
var filtered = _allInvoices.filter(function(inv) {
|
| 71 |
+
if (!q) return true;
|
| 72 |
+
return (
|
| 73 |
+
(inv.invNumber || "").toLowerCase().includes(q) ||
|
| 74 |
+
(inv.doctor || "").toLowerCase().includes(q) ||
|
| 75 |
+
(inv.clinic || "").toLowerCase().includes(q) ||
|
| 76 |
+
(inv.patient || "").toLowerCase().includes(q) ||
|
| 77 |
+
(inv.status || "").toLowerCase().includes(q)
|
| 78 |
+
);
|
| 79 |
+
});
|
| 80 |
+
_render(filtered);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* Delete an invoice after confirmation */
|
| 84 |
+
function deleteInvoice(id) {
|
| 85 |
+
if (!confirm("Delete this invoice permanently?")) return;
|
| 86 |
+
dbDelete(id).then(function() {
|
| 87 |
+
showToast("ποΈ Invoice deleted", "error");
|
| 88 |
+
load(); // refresh the list
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* Get a single invoice by id from cache */
|
| 93 |
+
function getById(id) {
|
| 94 |
+
return _allInvoices.find(function(inv) { return inv.id === id; }) || null;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/* Public API */
|
| 98 |
+
return { load: load, filter: filter, deleteInvoice: deleteInvoice, getById: getById };
|
| 99 |
+
|
| 100 |
+
})();
|
frontend/js/rows.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/rows.js β Service row management
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
var Rows = (function() {
|
| 6 |
+
|
| 7 |
+
var _rowCounter = 0; // unique key for each row
|
| 8 |
+
|
| 9 |
+
/* Build the <select> options HTML for the services list */
|
| 10 |
+
function _serviceOptions(selected) {
|
| 11 |
+
var html = '<option value="">β Select Service β</option>';
|
| 12 |
+
SERVICES.forEach(function(s) {
|
| 13 |
+
html += '<option' + (s === selected ? ' selected' : '') + '>' + s + '</option>';
|
| 14 |
+
});
|
| 15 |
+
return html;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* Create one table row of HTML */
|
| 19 |
+
function _buildRow(id, desc, qty, price, total) {
|
| 20 |
+
desc = desc || "";
|
| 21 |
+
qty = qty !== undefined ? qty : 1;
|
| 22 |
+
price = price !== undefined ? price : "";
|
| 23 |
+
total = total || 0;
|
| 24 |
+
|
| 25 |
+
return [
|
| 26 |
+
'<tr id="row-' + id + '">',
|
| 27 |
+
'<td class="sno row-index"></td>',
|
| 28 |
+
'<td>',
|
| 29 |
+
'<select class="tbl-select" onchange="Rows.update(' + id + ')">' + _serviceOptions(desc) + '</select>',
|
| 30 |
+
'</td>',
|
| 31 |
+
'<td>',
|
| 32 |
+
'<input type="number" class="tbl-input" min="1" value="' + qty + '"',
|
| 33 |
+
' style="text-align:center" oninput="Rows.update(' + id + ')"/>',
|
| 34 |
+
'</td>',
|
| 35 |
+
'<td>',
|
| 36 |
+
'<input type="number" class="tbl-input right" min="0" value="' + price + '" placeholder="0"',
|
| 37 |
+
' oninput="Rows.update(' + id + ')"/>',
|
| 38 |
+
'</td>',
|
| 39 |
+
'<td class="total-cell row-total">' + fmtNum(total) + '</td>',
|
| 40 |
+
'<td class="action-cell">',
|
| 41 |
+
'<button class="btn-del" onclick="Rows.remove(' + id + ')">Γ</button>',
|
| 42 |
+
'</td>',
|
| 43 |
+
'</tr>',
|
| 44 |
+
].join("");
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* Renumber the # column sequentially */
|
| 48 |
+
function _renumber() {
|
| 49 |
+
var cells = document.querySelectorAll("#rows-body .row-index");
|
| 50 |
+
cells.forEach(function(cell, i) { cell.textContent = i + 1; });
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* Recalculate a single row's total and update the summary */
|
| 54 |
+
function update(id) {
|
| 55 |
+
var row = document.getElementById("row-" + id);
|
| 56 |
+
if (!row) return;
|
| 57 |
+
|
| 58 |
+
var inputs = row.querySelectorAll("input[type=number]");
|
| 59 |
+
var qty = parseFloat(inputs[0].value) || 0;
|
| 60 |
+
var price = parseFloat(inputs[1].value) || 0;
|
| 61 |
+
var total = qty * price;
|
| 62 |
+
|
| 63 |
+
row.querySelector(".row-total").textContent = fmtNum(total);
|
| 64 |
+
_recalcSummary();
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* Recalculate subtotal / remaining and refresh the summary panel */
|
| 68 |
+
function _recalcSummary() {
|
| 69 |
+
var subtotal = 0;
|
| 70 |
+
document.querySelectorAll("#rows-body tr").forEach(function(row) {
|
| 71 |
+
var inputs = row.querySelectorAll("input[type=number]");
|
| 72 |
+
var qty = parseFloat(inputs[0] ? inputs[0].value : 0) || 0;
|
| 73 |
+
var price = parseFloat(inputs[1] ? inputs[1].value : 0) || 0;
|
| 74 |
+
subtotal += qty * price;
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
var received = parseFloat(document.getElementById("received-input").value) || 0;
|
| 78 |
+
var remaining = subtotal - received;
|
| 79 |
+
|
| 80 |
+
document.getElementById("summary-subtotal").textContent = fmt(subtotal);
|
| 81 |
+
document.getElementById("summary-total").textContent = fmt(subtotal);
|
| 82 |
+
document.getElementById("summary-remaining").textContent = fmt(remaining);
|
| 83 |
+
document.getElementById("summary-remaining").style.color =
|
| 84 |
+
remaining > 0 ? "var(--red)" : "var(--green)";
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* Add a blank row */
|
| 88 |
+
function add() {
|
| 89 |
+
var id = ++_rowCounter;
|
| 90 |
+
var tbody = document.getElementById("rows-body");
|
| 91 |
+
tbody.insertAdjacentHTML("beforeend", _buildRow(id));
|
| 92 |
+
_renumber();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Remove a row by id */
|
| 96 |
+
function remove(id) {
|
| 97 |
+
var row = document.getElementById("row-" + id);
|
| 98 |
+
if (row) { row.remove(); _renumber(); _recalcSummary(); }
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/* Clear all rows and add two blank ones */
|
| 102 |
+
function reset() {
|
| 103 |
+
document.getElementById("rows-body").innerHTML = "";
|
| 104 |
+
add();
|
| 105 |
+
add();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* Load rows from saved data (array of row objects) */
|
| 109 |
+
function load(savedRows) {
|
| 110 |
+
document.getElementById("rows-body").innerHTML = "";
|
| 111 |
+
(savedRows || []).forEach(function(r) {
|
| 112 |
+
var id = ++_rowCounter;
|
| 113 |
+
document.getElementById("rows-body")
|
| 114 |
+
.insertAdjacentHTML("beforeend", _buildRow(id, r.desc, r.qty, r.price, r.total));
|
| 115 |
+
});
|
| 116 |
+
_renumber();
|
| 117 |
+
_recalcSummary();
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Collect current row data into an array of plain objects */
|
| 121 |
+
function collect() {
|
| 122 |
+
var result = [];
|
| 123 |
+
document.querySelectorAll("#rows-body tr").forEach(function(row) {
|
| 124 |
+
var sel = row.querySelector("select");
|
| 125 |
+
var inputs = row.querySelectorAll("input[type=number]");
|
| 126 |
+
var qty = parseFloat(inputs[0] ? inputs[0].value : 1) || 1;
|
| 127 |
+
var price = parseFloat(inputs[1] ? inputs[1].value : 0) || 0;
|
| 128 |
+
result.push({
|
| 129 |
+
desc: sel ? sel.value : "",
|
| 130 |
+
qty: qty,
|
| 131 |
+
price: price,
|
| 132 |
+
total: qty * price,
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
return result;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* Get the current subtotal */
|
| 139 |
+
function subtotal() {
|
| 140 |
+
return collect().reduce(function(sum, r) { return sum + r.total; }, 0);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Wire up the received-input to trigger recalc */
|
| 144 |
+
document.addEventListener("DOMContentLoaded", function() {
|
| 145 |
+
document.getElementById("received-input").addEventListener("input", _recalcSummary);
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
/* Public API */
|
| 149 |
+
return { add: add, remove: remove, reset: reset, load: load, collect: collect, subtotal: subtotal, update: update };
|
| 150 |
+
|
| 151 |
+
})();
|
frontend/js/ui.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* βββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
js/ui.js β UI helpers & shared utils
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββ */
|
| 4 |
+
|
| 5 |
+
/* Show a toast notification
|
| 6 |
+
type: "success" | "error" | "info" */
|
| 7 |
+
function showToast(msg, type) {
|
| 8 |
+
type = type || "success";
|
| 9 |
+
var el = document.getElementById("toast");
|
| 10 |
+
el.textContent = msg;
|
| 11 |
+
el.className = "show " + type;
|
| 12 |
+
clearTimeout(el._timer);
|
| 13 |
+
el._timer = setTimeout(function() { el.className = ""; }, 3000);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/* Format a number as PKR currency */
|
| 17 |
+
function fmt(n) {
|
| 18 |
+
return "PKR " + (Number(n) || 0).toLocaleString("en-PK", { minimumFractionDigits: 0 });
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/* Format a number with commas (no currency prefix) */
|
| 22 |
+
function fmtNum(n) {
|
| 23 |
+
return (Number(n) || 0).toLocaleString("en-PK");
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* Return today's date as YYYY-MM-DD */
|
| 27 |
+
function todayStr() {
|
| 28 |
+
return new Date().toISOString().split("T")[0];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* Show/hide the two main pages and set the active tab */
|
| 32 |
+
function switchPage(page) {
|
| 33 |
+
document.getElementById("page-invoice").style.display = (page === "invoice") ? "" : "none";
|
| 34 |
+
document.getElementById("page-history").style.display = (page === "history") ? "" : "none";
|
| 35 |
+
|
| 36 |
+
document.getElementById("tab-invoice").classList.toggle("active", page === "invoice");
|
| 37 |
+
document.getElementById("tab-history").classList.toggle("active", page === "history");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* Update the live invoice number & status badge in the header band */
|
| 41 |
+
function updateHeaderBadge() {
|
| 42 |
+
var num = document.getElementById("inv-number").value || "INV-0001";
|
| 43 |
+
var status = document.getElementById("inv-status").value || "Pending";
|
| 44 |
+
|
| 45 |
+
document.getElementById("display-inv-number").textContent = "#" + num;
|
| 46 |
+
|
| 47 |
+
var badge = document.getElementById("display-status");
|
| 48 |
+
badge.textContent = status;
|
| 49 |
+
badge.className = "status-badge status-" + status.toLowerCase();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* Wire up live badge updates once DOM is ready */
|
| 53 |
+
document.addEventListener("DOMContentLoaded", function() {
|
| 54 |
+
document.getElementById("inv-number").addEventListener("input", updateHeaderBadge);
|
| 55 |
+
document.getElementById("inv-status").addEventListener("change", updateHeaderBadge);
|
| 56 |
+
});
|
main.py
CHANGED
|
@@ -9,8 +9,8 @@ from api.invoices import router as invoice_router
|
|
| 9 |
Base.metadata.create_all(bind=engine)
|
| 10 |
|
| 11 |
app = FastAPI(title="SmiloCAD Invoice API")
|
| 12 |
-
app.mount("/css", StaticFiles(directory="
|
| 13 |
-
app.mount("/js", StaticFiles(directory="
|
| 14 |
|
| 15 |
app.add_middleware(
|
| 16 |
CORSMiddleware,
|
|
@@ -22,8 +22,11 @@ app.add_middleware(
|
|
| 22 |
# 1. Route to serve the index.html
|
| 23 |
@app.get("/")
|
| 24 |
async def serve_index():
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
| 27 |
# This matches your folder names: frontend/css and frontend/js
|
| 28 |
|
| 29 |
# Include the routes from the api folder
|
|
|
|
| 9 |
Base.metadata.create_all(bind=engine)
|
| 10 |
|
| 11 |
app = FastAPI(title="SmiloCAD Invoice API")
|
| 12 |
+
app.mount("/css", StaticFiles(directory="frontend/css"), name="css")
|
| 13 |
+
app.mount("/js", StaticFiles(directory="frontend/js"), name="js")
|
| 14 |
|
| 15 |
app.add_middleware(
|
| 16 |
CORSMiddleware,
|
|
|
|
| 22 |
# 1. Route to serve the index.html
|
| 23 |
@app.get("/")
|
| 24 |
async def serve_index():
|
| 25 |
+
# Return index.html from the new local frontend folder
|
| 26 |
+
return FileResponse("frontend/index.html")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# 2. Mount the CSS and JS folders so the HTML can find them
|
| 30 |
# This matches your folder names: frontend/css and frontend/js
|
| 31 |
|
| 32 |
# Include the routes from the api folder
|