Docker Commit

This commit is contained in:
Huzaifa Inam 2026-08-07 14:28:35 +05:00
parent 541f2ac0c0
commit 76e0eb782d
7 changed files with 178 additions and 70 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.md
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
portal-data.json
.env
.env.local

View file

@ -1,9 +1,13 @@
# GEMINI_API_KEY: Required for Gemini AI API calls. # Server Configuration
# AI Studio automatically injects this at runtime from user secrets. PORT=3000
# Users configure this via the Secrets panel in the AI Studio UI. NODE_ENV=production
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted. # Database & Data Persistence Path
# AI Studio automatically injects this at runtime with the Cloud Run service URL. DATA_PATH=/app/data/portal-data.json
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL" # Optional SMTP Email Configuration (If using real SMTP server)
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_USER=your-email@domain.com
# SMTP_PASS=your-app-password
# SMTP_FROM="Balance Sheet Portal <noreply@domain.com>"

69
COOLIFY.md Normal file
View file

@ -0,0 +1,69 @@
# Deployment Guide for Coolify
This repository is optimized for deployment on **Coolify** using Docker or Docker Compose.
---
## Quick Deployment Options in Coolify
### Option A: Deployment via Git Repository (Recommended)
1. **Push Code to Git**: Push this repository to GitHub, GitLab, or your self-hosted Git service.
2. **Add New Resource in Coolify**:
- Go to your Coolify dashboard.
- Click **+ Add Resource** -> **Public Repository** or **Private Repository**.
- Paste your repository URL and select the `main` branch.
3. **Select Build Pack**:
- Select **Dockerfile**. Coolify will automatically detect the `Dockerfile` in the root directory.
4. **Configure Port & Network**:
- **Port**: Set `3000`.
5. **Configure Persistent Volume**:
- Under **Storage / Volumes**, add a persistent volume mapping to prevent data loss on container redeployments:
- **Destination Path**: `/app/data`
6. **Deploy**:
- Click **Deploy**. Coolify will build the multi-stage Docker image and start your application.
---
### Option B: Deployment via Docker Compose
1. **Add New Resource in Coolify**:
- Click **+ Add Resource** -> **Docker Compose**.
2. **Source Code**:
- Select your Git Repository or paste the contents of `docker-compose.yml`.
3. **Environment Variables**:
- Add the following environment variables in Coolify UI:
```env
NODE_ENV=production
PORT=3000
DATA_PATH=/app/data/portal-data.json
```
4. **Deploy**:
- Click **Deploy**. Coolify will spin up the container and attach the `portal_data` volume automatically.
---
## Local Testing with Docker
To build and test locally before deploying to Coolify:
### Using Docker Compose:
```bash
docker-compose up --build -d
```
Access the app at: `http://localhost:3000`
### Using Docker directly:
```bash
docker build -t balance-sheet-portal .
docker run -p 3000:3000 -v portal_data:/app/data balance-sheet-portal
```
---
## Health Check Endpoint
The container includes a built-in health check targeting:
`GET /api/health`
It returns `{"status": "ok"}` when the Express server and Vite static engine are healthy.

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
version: '3.8'
services:
balance-sheet-portal:
build:
context: .
dockerfile: Dockerfile
image: balance-sheet-portal:latest
container_name: balance-sheet-portal
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- DATA_PATH=/app/data/portal-data.json
volumes:
- portal_data:/app/data
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
portal_data:
name: balance_sheet_portal_data
driver: local

View file

@ -40,7 +40,7 @@ import {
import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js'; import { AccountingEngine, DEFAULT_FX_RATES } from './src/services/accountingEngine.js';
const app = express(); const app = express();
const PORT = 3000; const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
app.use(express.json()); app.use(express.json());
@ -62,7 +62,8 @@ interface DB {
pkrRates?: Record<string, number>; pkrRates?: Record<string, number>;
} }
const dbPath = path.join(process.cwd(), 'portal-data.json'); const dataDir = process.env.DATA_DIR || process.cwd();
const dbPath = process.env.DATA_PATH || path.join(dataDir, 'portal-data.json');
const DEFAULT_PKR_RATES: Record<string, number> = { const DEFAULT_PKR_RATES: Record<string, number> = {
USDPKR: 278.16, USDPKR: 278.16,
@ -177,6 +178,10 @@ function loadDB(): DB {
function saveDB(data: DB) { function saveDB(data: DB) {
try { try {
const parentDir = path.dirname(dbPath);
if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
fs.writeFileSync(dbPath, JSON.stringify(data, null, 2)); fs.writeFileSync(dbPath, JSON.stringify(data, null, 2));
} catch (err) { } catch (err) {
console.error('Error saving portal-data.json:', err); console.error('Error saving portal-data.json:', err);

View file

@ -78,6 +78,11 @@ export default function App() {
} }
}; };
// Handle user logout
const handleLogout = () => {
setIsAuthModalOpen(true);
};
return ( return (
<div className="relative h-screen w-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row overflow-hidden font-sans antialiased selection:bg-cyan-500 selection:text-black"> <div className="relative h-screen w-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row overflow-hidden font-sans antialiased selection:bg-cyan-500 selection:text-black">
{/* 3D WebGL Holographic Particle Canvas Background */} {/* 3D WebGL Holographic Particle Canvas Background */}
@ -90,6 +95,7 @@ export default function App() {
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
allUsers={allUsers} allUsers={allUsers}
onLogout={handleLogout}
onOpenAuthModal={() => setIsAuthModalOpen(true)} onOpenAuthModal={() => setIsAuthModalOpen(true)}
/> />
@ -105,7 +111,7 @@ export default function App() {
setActivePeriod={setActivePeriod} setActivePeriod={setActivePeriod}
periodsList={periodsList} periodsList={periodsList}
allUsers={allUsers} allUsers={allUsers}
onResetSeed={handleResetSeed} onLogout={handleLogout}
onOpenAuthModal={() => setIsAuthModalOpen(true)} onOpenAuthModal={() => setIsAuthModalOpen(true)}
/> />

View file

@ -25,7 +25,8 @@ import {
PanelLeftOpen, PanelLeftOpen,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Key Key,
LogOut
} from 'lucide-react'; } from 'lucide-react';
import { User, BranchId, BRANCHES_LIST } from '../types'; import { User, BranchId, BRANCHES_LIST } from '../types';
@ -38,7 +39,8 @@ interface NavigationProps {
setActivePeriod: (period: string) => void; setActivePeriod: (period: string) => void;
periodsList: string[]; periodsList: string[];
allUsers: User[]; allUsers: User[];
onResetSeed: () => void; onLogout?: () => void;
onResetSeed?: () => void;
onOpenAuthModal?: () => void; onOpenAuthModal?: () => void;
} }
@ -49,6 +51,7 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
activeTab, activeTab,
setActiveTab, setActiveTab,
allUsers, allUsers,
onLogout,
onOpenAuthModal onOpenAuthModal
}) => { }) => {
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
@ -315,16 +318,11 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
</nav> </nav>
{/* Sidebar Footer User Profile */} {/* Sidebar Footer User Profile */}
<div className="p-2 border-t border-cyan-500/20 bg-slate-950/90 relative shrink-0"> <div className="p-2 border-t border-cyan-500/20 bg-slate-950/90 relative shrink-0 space-y-1.5">
<button <div
onClick={() => {
setShowUserMenu(false);
onOpenAuthModal?.();
}}
className={`w-full flex items-center ${ className={`w-full flex items-center ${
isCollapsed ? 'justify-center p-2' : 'justify-between p-2' isCollapsed ? 'justify-center p-2' : 'justify-between p-2'
} rounded-xl bg-slate-900/60 border border-cyan-500/30 hover:border-cyan-400/60 transition-all text-left cursor-pointer shadow-sm`} } rounded-xl bg-slate-900/60 border border-cyan-500/30 text-left`}
title={`Active Session: ${currentUser.name} - Click to Authenticate Credentials`}
> >
<div className="flex items-center gap-2.5 overflow-hidden"> <div className="flex items-center gap-2.5 overflow-hidden">
<div className="w-8 h-8 rounded-full bg-cyan-500/20 border border-cyan-400/60 flex items-center justify-center text-cyan-300 font-bold text-xs shrink-0 shadow-[0_0_10px_rgba(6,182,212,0.4)]"> <div className="w-8 h-8 rounded-full bg-cyan-500/20 border border-cyan-400/60 flex items-center justify-center text-cyan-300 font-bold text-xs shrink-0 shadow-[0_0_10px_rgba(6,182,212,0.4)]">
@ -336,12 +334,24 @@ export const Sidebar: React.FC<Omit<NavigationProps, 'activePeriod' | 'setActive
{currentUser.name} {currentUser.name}
{isAdmin && <ShieldCheck className="w-3 h-3 text-cyan-400 shrink-0" />} {isAdmin && <ShieldCheck className="w-3 h-3 text-cyan-400 shrink-0" />}
</p> </p>
<p className="text-[10px] text-cyan-400 font-mono truncate font-semibold">Authenticate / Switch</p> <p className="text-[10px] text-cyan-400 font-mono truncate font-semibold">
{isAdmin ? 'Head Office Admin' : currentBranch?.name || 'Branch User'}
</p>
</div> </div>
)} )}
</div> </div>
{!isCollapsed && <Key className="w-3.5 h-3.5 text-amber-400 shrink-0" />} </div>
</button>
{!isCollapsed && onLogout && (
<button
onClick={onLogout}
className="w-full py-2 px-3 rounded-xl bg-slate-900 hover:bg-rose-950/50 border border-rose-500/40 hover:border-rose-500/70 text-rose-300 hover:text-rose-100 text-xs font-bold flex items-center justify-center gap-2 transition-all cursor-pointer shadow-sm shadow-rose-950/20"
title="Sign out of current banking session"
>
<LogOut className="w-3.5 h-3.5 text-rose-400" />
<span>Sign Out</span>
</button>
)}
</div> </div>
</aside> </aside>
); );
@ -357,6 +367,7 @@ export const HeaderBar: React.FC<NavigationProps> = ({
setActivePeriod, setActivePeriod,
periodsList, periodsList,
allUsers, allUsers,
onLogout,
onResetSeed, onResetSeed,
onOpenAuthModal onOpenAuthModal
}) => { }) => {
@ -439,25 +450,16 @@ export const HeaderBar: React.FC<NavigationProps> = ({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onOpenAuthModal && ( {onLogout && (
<button <button
onClick={onOpenAuthModal} onClick={onLogout}
className="bg-gradient-to-r from-cyan-500/20 via-blue-500/20 to-indigo-500/20 hover:from-cyan-500/30 hover:to-indigo-500/30 text-cyan-300 text-xs px-2.5 py-1.5 rounded-lg font-bold border border-cyan-400/50 transition-all flex items-center gap-1.5 cursor-pointer shadow-[0_0_10px_rgba(6,182,212,0.2)]" className="bg-slate-900 hover:bg-rose-950/50 text-rose-300 hover:text-rose-200 text-xs px-3 py-1.5 rounded-lg font-bold border border-rose-500/40 hover:border-rose-500/70 transition-all flex items-center gap-1.5 cursor-pointer shadow-sm shadow-rose-950/20"
title="Authenticate Branch Credentials or Switch User Persona" title="Sign out of current active session"
> >
<ShieldCheck className="w-3.5 h-3.5 text-cyan-400" /> <LogOut className="w-3.5 h-3.5 text-rose-400" />
<span className="hidden sm:inline">Auth Matrix</span> <span className="hidden sm:inline">Sign Out</span>
</button> </button>
)} )}
<button
onClick={onResetSeed}
className="bg-slate-900 hover:bg-slate-800 text-cyan-300 hover:text-white text-xs px-2.5 py-1.5 rounded-lg font-semibold border border-cyan-500/30 transition-all flex items-center gap-1.5 cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(6,182,212,0.3)]"
title="Reset sample data back to baseline"
>
<RotateCcw className="w-3.5 h-3.5 text-cyan-400" />
<span className="hidden sm:inline">Reset Demo</span>
</button>
</div> </div>
</div> </div>
@ -582,38 +584,19 @@ export const HeaderBar: React.FC<NavigationProps> = ({
)} )}
</div> </div>
{/* Quick User Identity Switcher for Mobile */} {/* Sign Out Button for Mobile */}
<div className="pt-2 border-t border-slate-800"> <div className="pt-2 border-t border-slate-800">
<button {onLogout && (
onClick={() => setShowUserMenu(!showUserMenu)} <button
className="w-full flex items-center justify-between p-2 bg-slate-900 rounded-lg border border-cyan-500/30 text-xs text-cyan-300" onClick={() => {
> setMobileMenuOpen(false);
<span>Simulate User: {currentUser.name}</span> onLogout();
<ChevronDown className="w-4 h-4 text-cyan-400" /> }}
</button> className="w-full flex items-center justify-center gap-2 p-2.5 bg-slate-900 hover:bg-rose-950/60 border border-rose-500/40 text-rose-300 text-xs font-bold rounded-xl transition-all cursor-pointer"
>
{showUserMenu && ( <LogOut className="w-4 h-4 text-rose-400" />
<div className="mt-2 p-2 bg-slate-900 rounded-lg border border-cyan-500/40 space-y-1 max-h-48 overflow-y-auto"> <span>Sign Out / Logout</span>
{allUsers.filter((u) => u.approved).map((user) => ( </button>
<button
key={user.id}
onClick={() => {
setCurrentUser(user);
setShowUserMenu(false);
setMobileMenuOpen(false);
if (user.role === 'branch_user') {
setActiveTab('branch_input');
}
}}
className={`w-full text-left p-1.5 rounded text-xs flex justify-between items-center ${
user.id === currentUser.id ? 'bg-cyan-500/30 text-white font-bold' : 'text-slate-300 hover:bg-slate-800'
}`}
>
<span>{user.name}</span>
<span className="text-[9px] font-mono text-cyan-400">{user.role}</span>
</button>
))}
</div>
)} )}
</div> </div>
</div> </div>