commit b1b621bc4ab0b1abaf239321b38829bb5461c830 Author: David Date: Mon May 11 12:48:35 2026 +1000 Initial commit -- just started to factor and implement python fast API backend diff --git a/Dashy-v1/scraps/sketch-2026-05-08T02-04-31-tkrnol.napkin b/Dashy-v1/scraps/sketch-2026-05-08T02-04-31-tkrnol.napkin new file mode 100644 index 0000000..8b2c027 --- /dev/null +++ b/Dashy-v1/scraps/sketch-2026-05-08T02-04-31-tkrnol.napkin @@ -0,0 +1,6 @@ +{ + "version": 1, + "created": "2026-05-08T02:04:31.986Z", + "modified": "2026-05-08T02:04:42.961Z", + "objects": [] +} \ No newline at end of file diff --git a/Dashy.html b/Dashy.html new file mode 100644 index 0000000..f909ea0 --- /dev/null +++ b/Dashy.html @@ -0,0 +1,24 @@ + + + + + + Dashy — task dashboard + + + +
+ + + + + + + + + + + + + + diff --git a/app.jsx b/app.jsx new file mode 100644 index 0000000..daa838e --- /dev/null +++ b/app.jsx @@ -0,0 +1,211 @@ +// Dashy — main app (DB-backed via SQLite/WASM) + +const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ + "theme": "light", + "accent": "#2A6FDB", + "density": "cozy", + "showTags": true +}/*EDITMODE-END*/; + +const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8']; + +function useDashyDB() { + const [ready, setReady] = React.useState(DashyDB.isReady); + const [, force] = React.useReducer(x => x + 1, 0); + React.useEffect(() => { + if (!DashyDB.isReady) DashyDB.init().then(() => setReady(true)); + const off = DashyDB.subscribe(() => force()); + return off; + }, []); + return ready; +} + +function App() { + const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); + const ready = useDashyDB(); + const [authed, setAuthed] = React.useState(false); + const [meId, setMeId] = React.useState('rod'); + const [tab, setTab] = React.useState('overview'); + const [adding, setAdding] = React.useState(null); + const [openTaskId, setOpenTaskId] = React.useState(null); + const [showLogs, setShowLogs] = React.useState(false); + const [showSettings, setShowSettings] = React.useState(false); + const [showDB, setShowDB] = React.useState(false); + const [headsUp, setHeadsUp] = React.useState([ + { id: 'h1', kind: 'unsuccessful', taskId: 't7', + title: 'WO #2188 auto-marked Unsuccessful', + sub: 'Two missed bookings — assigned to Kirra for review' }, + { id: 'h2', kind: 'billing', taskId: 't4', + title: 'Form response switched to Billing', + sub: 'K. Wynne · originally Service Booking' }, + ]); + + if (!ready) return ; + + const tasks = DashyDB.listTasks(); + const audit = DashyDB.listAudit(); + const dbUsers = DashyDB.listUsers(); + const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u])); + const merge = (id) => { + const base = findUser(id); if (!base && !userMap[id]) return null; + const live = userMap[id] || {}; + if (!base) { + // user added at runtime + return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials, + photo: live.photo || null, account_type: live.account_type || 'standard' }; + } + return { ...base, name: live.name || base.name, role: live.role || base.role, + photo: live.photo || null, account_type: live.account_type || 'standard' }; + }; + const me = merge(meId); + const isAdmin = me && me.account_type === 'admin'; + const openTask = tasks.find(x => x.id === openTaskId); + + const handleLogin = (id) => { + setMeId(id); setAuthed(true); + DashyDB.addAudit({ + actor: id, action: 'login', + summary: (merge(id) || {}).name + ' signed in', + }); + }; + + const addTask = ({ title, description, assignee, priority }) => { + const id = 't_' + Date.now().toString(36); + const at = new Date('2026-05-08T10:30:00').toISOString(); + DashyDB.createTask({ + id, title, description, assignee, priority, + addedBy: meId, source: 'manual', status: 'open', addedAt: at, tags: [] + }); + DashyDB.addAudit({ + at, actor: meId, action: 'task_created', + summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name, + target: id + }); + setAdding(null); + }; + + const moveTask = (taskId, toUserId) => DashyDB.moveTask(taskId, toUserId, meId); + const setPriority = (taskId, p) => DashyDB.setPriority(taskId, p); + const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id)); + const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; + + if (!authed) return ; + + return ( +
+ setAdding(meId)} + onLogs={() => setShowLogs(true)} + onProfile={() => setShowSettings(true)} + onDB={() => setShowDB(true)} + /> + + + +
+ {tab === 'overview' && ( + setOpenTaskId(task.id)} + onAddFor={(uid) => setAdding(uid)} + onMoveTask={moveTask} + /> + )} + {tab !== 'overview' && ( + setOpenTaskId(task.id)} + onAddFor={(uid) => setAdding(uid)} + /> + )} +
+ + setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} /> + {openTask && ( + setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} /> + )} + {showLogs && ( + setShowLogs(false)} wide> + + + )} + {showSettings && ( + setShowSettings(false)} + onSave={(edits) => { + DashyDB.updateUser(meId, edits); + DashyDB.addAudit({ actor: meId, action: 'profile_updated', summary: 'Updated profile details' }); + }} + onLogout={() => { setShowSettings(false); setAuthed(false); }} + onSwitchUser={(id) => { setMeId(id); setShowSettings(false); }} + onCreateUser={(u) => { + const id = DashyDB.createUser(u); + DashyDB.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id }); + }} + onDeleteUser={(id) => { + const u = userMap[id]; + DashyDB.deleteUser(id); + DashyDB.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null }); + }} + onUpdateUserRole={(id, edits) => { + DashyDB.updateUser(id, edits); + DashyDB.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null }); + }} + /> + )} + {showDB && isAdmin && setShowDB(false)} />} + + +
+ ); +} + +function BootSplash() { + return ( +
+
+
opening dashy.db…
+
+ ); +} + +function DashyTweaks({ t, setTweak }) { + return ( + + + setTweak('theme', v)} /> + setTweak('accent', v)} /> + setTweak('density', v)} /> + setTweak('showTags', v)} /> + + + { if (confirm('Wipe and reseed dashy.db?')) DashyDB.reset(); }}> + Reset + + + + ); +} + +function ThemeBridge() { + const [t] = useTweaks(TWEAK_DEFAULTS); + React.useEffect(() => { + document.documentElement.dataset.theme = t.theme; + document.documentElement.style.setProperty('--accent', t.accent); + document.documentElement.dataset.showTags = t.showTags ? 'on' : 'off'; + }, [t.theme, t.accent, t.showTags]); + return null; +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(<>); diff --git a/backend/__pycache__/auth.cpython-310.pyc b/backend/__pycache__/auth.cpython-310.pyc new file mode 100644 index 0000000..f3bdfb1 Binary files /dev/null and b/backend/__pycache__/auth.cpython-310.pyc differ diff --git a/backend/__pycache__/database.cpython-310.pyc b/backend/__pycache__/database.cpython-310.pyc new file mode 100644 index 0000000..d6e6df7 Binary files /dev/null and b/backend/__pycache__/database.cpython-310.pyc differ diff --git a/backend/__pycache__/models.cpython-310.pyc b/backend/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..2ad71b6 Binary files /dev/null and b/backend/__pycache__/models.cpython-310.pyc differ diff --git a/backend/__pycache__/seed.cpython-310.pyc b/backend/__pycache__/seed.cpython-310.pyc new file mode 100644 index 0000000..40c789c Binary files /dev/null and b/backend/__pycache__/seed.cpython-310.pyc differ diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..bacb129 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,27 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext + +# SECRET_KEY should be in an environment variable in production +SECRET_KEY = "your-secret-key-change-this-in-production" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..c0415b8 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +import os + +# Use an environment variable for the DB path, or default to local dashy.db +# On CIFS mounts, SQLite locking will fail, so we recommend a local path. +DB_PATH = os.getenv("DASHY_DB_PATH", "./dashy.db") +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False, "timeout": 30} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..4a11083 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,101 @@ +from fastapi import FastAPI, Depends, HTTPException, status +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from typing import List +import uuid + +from . import models, schemas, auth, database +from .database import engine, get_db + +models.Base.metadata.create_all(bind=engine) + +app = FastAPI(title="Dashy API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify your frontend URL + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.post("/token", response_model=schemas.Token) +async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)): + user = db.query(models.User).filter(models.User.id == form_data.id).first() + if not user or not auth.verify_password(form_data.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token = auth.create_access_token(data={"sub": user.id}) + return {"access_token": access_token, "token_type": "bearer"} + +@app.get("/users", response_model=List[schemas.User]) +def read_users(db: Session = Depends(get_db)): + return db.query(models.User).all() + +@app.get("/tasks", response_model=List[schemas.Task]) +def read_tasks(db: Session = Depends(get_db)): + return db.query(models.Task).all() + +@app.post("/tasks", response_model=schemas.Task) +def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): + task_id = task.id or f"t_{uuid.uuid4().hex[:8]}" + db_task = models.Task( + id=task_id, + title=task.title, + description=task.description, + assignee_id=task.assignee_id, + added_by=task.added_by, + priority=task.priority, + source=task.source, + status=task.status, + due_at=task.due_at, + reminder_at=task.reminder_at + ) + db.add(db_task) + + for tag_name in task.tags: + tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first() + if not tag: + tag = models.Tag(tag=tag_name) + db.add(tag) + db_task.tags.append(tag) + + db.commit() + db.refresh(db_task) + return db_task + +@app.patch("/tasks/{task_id}", response_model=schemas.Task) +def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db)): + db_task = db.query(models.Task).filter(models.Task.id == task_id).first() + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + + update_data = task_update.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_task, key, value) + + db.commit() + db.refresh(db_task) + return db_task + +@app.get("/audit", response_model=List[schemas.AuditLog]) +def read_audit(db: Session = Depends(get_db)): + return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all() + +@app.post("/audit", response_model=schemas.AuditLog) +def create_audit(audit: schemas.AuditLogBase, db: Session = Depends(get_db)): + audit_id = f"a_{uuid.uuid4().hex[:8]}" + db_audit = models.AuditLog( + id=audit_id, + actor=audit.actor, + action=audit.action, + summary=audit.summary, + target=audit.target + ) + db.add(db_audit) + db.commit() + db.refresh(db_audit) + return db_audit diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..589fa35 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,84 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Table, DateTime, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from .database import Base + +task_tags = Table( + "task_tags", + Base.metadata, + Column("task_id", String, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), + Column("tag", String, ForeignKey("tags.tag", ondelete="CASCADE"), primary_key=True), +) + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True) + name = Column(String, nullable=False) + role = Column(String, nullable=False) + hue = Column(Integer, nullable=False) + initials = Column(String, nullable=False) + email = Column(String) + phone = Column(String) + photo = Column(String) + password_hash = Column(String) + account_type = Column(String, nullable=False, default="standard") + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + tasks = relationship("Task", back_populates="assignee", foreign_keys="Task.assignee_id") + notes = relationship("TaskNote", back_populates="author") + +class Task(Base): + __tablename__ = "tasks" + + id = Column(String, primary_key=True) + title = Column(String, nullable=False) + description = Column(String) + assignee_id = Column(String, ForeignKey("users.id"), nullable=False) + added_by = Column(String, nullable=False) + priority = Column(String, nullable=False) # low, med, high + source = Column(String, nullable=False) # manual, imessage, email, automation + status = Column(String, nullable=False, default="open") + added_at = Column(DateTime(timezone=True), server_default=func.now()) + due_at = Column(DateTime(timezone=True)) + reminder_at = Column(DateTime(timezone=True)) + + assignee = relationship("User", back_populates="tasks", foreign_keys=[assignee_id]) + tags = relationship("Tag", secondary=task_tags, back_populates="tasks") + notes = relationship("TaskNote", back_populates="task", cascade="all, delete") + +class Tag(Base): + __tablename__ = "tags" + tag = Column(String, primary_key=True) + tasks = relationship("Task", secondary=task_tags, back_populates="tags") + +class TaskNote(Base): + __tablename__ = "task_notes" + + id = Column(Integer, primary_key=True, index=True) + task_id = Column(String, ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False) + author_id = Column(String, ForeignKey("users.id"), nullable=False) + body = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + task = relationship("Task", back_populates="notes") + author = relationship("User", back_populates="notes") + +class AuditLog(Base): + __tablename__ = "audit_log" + + id = Column(String, primary_key=True) + at = Column(DateTime(timezone=True), server_default=func.now()) + actor = Column(String, nullable=False) + action = Column(String, nullable=False) + summary = Column(String, nullable=False) + target = Column(String) + +class Session(Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String, ForeignKey("users.id"), nullable=False) + device = Column(String, nullable=False) + location = Column(String) + last_active = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9e64384 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn[standard] +sqlalchemy +pydantic +pydantic-settings +python-jose[cryptography] +passlib +bcrypt==4.0.1 +python-multipart diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..ba9b666 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,106 @@ +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from datetime import datetime + +class UserBase(BaseModel): + id: str + name: str + role: str + hue: int + initials: str + email: Optional[str] = None + phone: Optional[str] = None + photo: Optional[str] = None + account_type: str = "standard" + +class UserCreate(UserBase): + password: str + +class User(UserBase): + created_at: datetime + class Config: + from_attributes = True + +class TaskNoteBase(BaseModel): + body: str + +class TaskNoteCreate(TaskNoteBase): + task_id: str + author_id: str + +class TaskNote(TaskNoteBase): + id: int + task_id: str + author_id: str + created_at: datetime + class Config: + from_attributes = True + +class TagBase(BaseModel): + tag: str + +class Tag(TagBase): + class Config: + from_attributes = True + +class TaskBase(BaseModel): + title: str + description: Optional[str] = None + assignee_id: str + added_by: str + priority: str + source: str + status: str = "open" + due_at: Optional[datetime] = None + reminder_at: Optional[datetime] = None + +class TaskCreate(TaskBase): + id: Optional[str] = None + tags: List[str] = [] + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + assignee_id: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + due_at: Optional[datetime] = None + reminder_at: Optional[datetime] = None + +class Task(TaskBase): + id: str + added_at: datetime + tags: List[Tag] = [] + notes: List[TaskNote] = [] + class Config: + from_attributes = True + +class AuditLogBase(BaseModel): + actor: str + action: str + summary: str + target: Optional[str] = None + +class AuditLog(AuditLogBase): + id: str + at: datetime + class Config: + from_attributes = True + +class SessionBase(BaseModel): + user_id: str + device: str + location: Optional[str] = None + +class Session(SessionBase): + id: int + last_active: datetime + class Config: + from_attributes = True + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..a1a901d --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,150 @@ +from sqlalchemy.orm import Session +from .database import SessionLocal, engine +from . import models, auth +from datetime import datetime + +# Import data directly as if it were coming from data.jsx +USERS = [ + {"id": "rod", "name": "Rod", "role": "Owner", "hue": 220, "initials": "R"}, + {"id": "lani", "name": "Lani", "role": "Admin", "hue": 340, "initials": "L"}, + {"id": "kirra", "name": "Kirra", "role": "Technician", "hue": 160, "initials": "K"}, + {"id": "ayron", "name": "Ayron", "role": "Technician", "hue": 40, "initials": "A"}, +] + +SEED_TASKS = [ + { "id": 't1', "title": 'Call back Mrs. Patel re: Hilux service quote', + "description": 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.', + "assignee": 'lani', "addedBy": 'rod', "priority": 'high', "source": 'imessage', + "addedAt": '2026-05-08T08:42:00', "status": 'open', + "tags": ['quote'] }, + { "id": 't2', "title": 'Email #3814 → Workorder #2207', + "description": 'Auto-converted from inbox. Tell customer ETA of Friday.', + "assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'email', + "addedAt": '2026-05-08T07:15:00', "status": 'open', + "tags": ['WO #2207'] }, + { "id": 't3', "title": 'Reorder coolant — 5L × 4', + "description": 'Stock card ran red on the morning sweep.', + "assignee": 'lani', "addedBy": 'kirra', "priority": 'low', "source": 'manual', + "addedAt": '2026-05-07T16:02:00', "status": 'open', "tags": [] }, + { "id": 't4', "title": 'Form response from "K. Wynne" auto-switched to Billing', + "description": 'Originally captured as Service Booking — heads up.', + "assignee": 'lani', "addedBy": 'system', "priority": 'med', "source": 'automation', + "addedAt": '2026-05-08T09:50:00', "status": 'billing', + "tags": ['form'] }, + { "id": 't5', "title": 'Diagnose intermittent misfire — Camry, WO #2199', + "description": 'Cust says it stutters between 60–80km/h once warm.', + "assignee": 'kirra', "addedBy": 'rod', "priority": 'high', "source": 'manual', + "addedAt": '2026-05-08T07:50:00', "status": 'open', + "tags": ['WO #2199'] }, + { "id": 't6', "title": 'Sign off on Ayron\'s brake job — Forester', + "description": 'Final torque check + road test before pickup at 3pm.', + "assignee": 'kirra', "addedBy": 'ayron', "priority": 'med', "source": 'manual', + "addedAt": '2026-05-08T09:05:00', "status": 'open', "tags": [] }, + { "id": 't7', "title": 'WO #2188 auto-marked Unsuccessful', + "description": 'Customer no-show twice — please review and decide on next step.', + "assignee": 'kirra', "addedBy": 'system', "priority": 'high', "source": 'automation', + "addedAt": '2026-05-08T06:00:00', "status": 'unsuccessful', + "tags": ['WO #2188'] }, + { "id": 't8', "title": 'Replace front pads + rotors — Forester', + "description": 'Parts arrived yesterday. Bay 2 from 10am.', + "assignee": 'ayron', "addedBy": 'kirra', "priority": 'med', "source": 'manual', + "addedAt": '2026-05-08T08:00:00', "status": 'open', + "tags": ['WO #2201'] }, + { "id": 't9', "title": 'Tidy bay 3 + sweep before lunch', + "description": '', + "assignee": 'ayron', "addedBy": 'rod', "priority": 'low', "source": 'imessage', + "addedAt": '2026-05-08T09:30:00', "status": 'open', "tags": [] }, + { "id": 't10', "title": 'Pickup parts from Repco @ 11:30', + "description": 'Two boxes for WO #2199 + an oil filter for stock.', + "assignee": 'ayron', "addedBy": 'lani', "priority": 'med', "source": 'manual', + "addedAt": '2026-05-08T08:20:00', "status": 'open', "tags": [] }, + { "id": 't11', "title": 'Approve quote on Job #2207 ($1,840)', + "description": 'Lani flagged this — needs your sign-off before sending.', + "assignee": 'rod', "addedBy": 'lani', "priority": 'high', "source": 'manual', + "addedAt": '2026-05-08T09:12:00', "status": 'open', + "tags": ['quote', 'WO #2207'] }, + { "id": 't12', "title": 'Review weekly automation report', + "description": '14 tasks created from email, 6 from iMessage, 2 form re-routes.', + "assignee": 'rod', "addedBy": 'system', "priority": 'low', "source": 'automation', + "addedAt": '2026-05-08T06:00:00', "status": 'open', "tags": [] }, +] + +SEED_AUDIT = [ + { "id": 'a1', "at": '2026-05-08T09:50:00', "actor": 'system', "action": 'form_rerouted', + "summary": 'Form from K. Wynne auto-switched: Service Booking → Billing form', + "target": 't4' }, + { "id": 'a2', "at": '2026-05-08T09:30:00', "actor": 'rod', "action": 'task_created', + "summary": 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage', + "target": 't9' }, + { "id": 'a3', "at": '2026-05-08T09:12:00', "actor": 'lani', "action": 'task_assigned', + "summary": 'Assigned "Approve quote on Job #2207" to ROD', + "target": 't11' }, + { "id": 'a4', "at": '2026-05-08T09:05:00', "actor": 'ayron', "action": 'task_moved', + "summary": 'Moved "Sign off on brake job" from Ayron → Kirra', + "target": 't6' }, +] + +def seed_db(): + # Create tables + models.Base.metadata.create_all(bind=engine) + + db = SessionLocal() + + # Add Users + for u in USERS: + is_admin = u['id'] in ['rod', 'ayron'] + db_user = models.User( + id=u['id'], + name=u['name'], + role=u['role'], + hue=u['hue'], + initials=u['initials'], + email=f"{u['id']}@murchison-auto.co", + phone="+64 27 555 0184", + password_hash=auth.get_password_hash("password123"), # Default password + account_type='admin' if is_admin else 'standard' + ) + db.merge(db_user) + + # Add Tasks + for t in SEED_TASKS: + db_task = models.Task( + id=t['id'], + title=t['title'], + description=t['description'], + assignee_id=t['assignee'], + added_by=t['addedBy'], + priority=t['priority'], + source=t['source'], + status=t['status'], + added_at=datetime.fromisoformat(t['addedAt']) + ) + db.merge(db_task) + + # Add tags + for tag_name in t.get('tags', []): + tag = db.query(models.Tag).filter(models.Tag.tag == tag_name).first() + if not tag: + tag = models.Tag(tag=tag_name) + db.add(tag) + if tag not in db_task.tags: + db_task.tags.append(tag) + + # Add Audit + for a in SEED_AUDIT: + db_audit = models.AuditLog( + id=a['id'], + at=datetime.fromisoformat(a['at']), + actor=a['actor'], + action=a['action'], + summary=a['summary'], + target=a['target'] + ) + db.merge(db_audit) + + db.commit() + db.close() + +if __name__ == "__main__": + seed_db() + print("Database seeded!") diff --git a/components.jsx b/components.jsx new file mode 100644 index 0000000..5233254 --- /dev/null +++ b/components.jsx @@ -0,0 +1,174 @@ +// Shared primitives for Dashy + +const USERS = [ + { id: 'lani', name: 'Lani', role: 'Front of house', hue: 28, initials: 'LA' }, + { id: 'kirra', name: 'Kirra', role: 'Workshop lead', hue: 145, initials: 'KI' }, + { id: 'ayron', name: 'Ayron', role: 'Tech', hue: 215, initials: 'AY' }, + { id: 'rod', name: 'ROD', role: 'Owner', hue: 280, initials: 'RO' }, +]; + +const PRIORITY = { + high: { label: 'High', color: 'var(--prio-high)', dot: 'var(--prio-high)' }, + med: { label: 'Med', color: 'var(--prio-med)', dot: 'var(--prio-med)' }, + low: { label: 'Low', color: 'var(--prio-low)', dot: 'var(--prio-low)' }, +}; + +const SOURCES = { + manual: { label: 'Manual', glyph: '·' }, + imessage: { label: 'iMessage', glyph: '✦' }, + email: { label: 'Email', glyph: '✉' }, + automation:{ label: 'Auto', glyph: '⚙' }, +}; + +function Avatar({ user, size = 28, ring = false }) { + if (!user) return null; + const s = { width: size, height: size, fontSize: Math.round(size * 0.42) }; + if (user.photo) { + return ( + + ); + } + return ( + + {user.initials} + + ); +} + +function PriorityDot({ priority, withLabel = false }) { + const p = PRIORITY[priority]; + if (!p) return null; + return ( + + + {withLabel && {p.label}} + + ); +} + +function SourceTag({ source }) { + const s = SOURCES[source]; + if (!s || source === 'manual') return null; + return ( + + {s.glyph} + {s.label} + + ); +} + +function relTime(iso) { + const now = new Date('2026-05-08T10:30:00'); + const then = new Date(iso); + const diff = (now - then) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff/60) + 'm ago'; + if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; + if (diff < 86400*7) return Math.floor(diff/86400) + 'd ago'; + return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function fmtDateTime(iso) { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + + ' · ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); +} + +function findUser(id) { return USERS.find(u => u.id === id); } + +function TaskCard({ task, onOpen, density = 'cozy', dragging = false, onDragStart, onDragEnd }) { + const author = findUser(task.addedBy); + const isAuto = task.status === 'unsuccessful' || task.status === 'billing'; + return ( +
{ + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/dashy-task', task.id); + onDragStart && onDragStart(task); + }} + onDragEnd={() => onDragEnd && onDragEnd()} + onClick={() => onOpen && onOpen(task)} + data-comment-anchor={"task-" + task.id} + > +
+ ); +} + +function IconBtn({ children, onClick, label, variant = 'ghost' }) { + return ( + + ); +} + +// Tiny inline icons +const Icon = { + Plus: () => , + Bell: () => , + Search: () => , + Logs: () => , + Close: () => , + Check: () => , + Arrow: () => , + Dot: () => , + Pin: () => , + iMessage: () => , +}; + +Object.assign(window, { + USERS, PRIORITY, SOURCES, + Avatar, PriorityDot, SourceTag, TaskCard, IconBtn, Icon, + relTime, fmtDateTime, findUser, +}); diff --git a/dashy.db b/dashy.db new file mode 100644 index 0000000..e69de29 diff --git a/dashy.md b/dashy.md new file mode 100644 index 0000000..7c23c48 --- /dev/null +++ b/dashy.md @@ -0,0 +1,47 @@ +##### Conditions + + - Multi User With auth per user + - Audit Logs for all actions (moving tasks, editing tasks, adding tasks and user auth) + - Create and mv tasks between users + - Notify User when new "task" is assigned to them + - iMessage --> Task + - Include date when tasks was added and who by + - Possibly a priority stamp? This would be on how important the task was needing to be completed.... + - Heads up display of when a task was automatically changed to "Unsuccessful" + - Heads up display of when automatic form response is changed to "Billing form" + - Easy way to add notes and reminders. + +##### What do I want this thing to do + + - I want to create task for Users to-do (like a reminder / to-do / task list) + - Pop up notifications for when a reminder comes through to you??? >>>> Idk if this is going to be a native windows application for something??? + - I think it would be handy to not only have this as a task "dashboard" but also as a way to keep track of what we have automated. + - it would be nice if when a task is taken from emails and added to whichever queue we decide to add it too -- it leaves a task in the dashy to tell staff (Lani in particular) "Hey email such and such has just been converted to workorder # (and have whatever the workorder number is) + - + + +##### iMessage --> Task ? What I mean + + So what do I mean when I say "iMessage too task" + + - What I mean by this is so that ROD can simply pick up his phone and ask siri or meta to send a message to "X" -- "X" being whatever he/we decide to name the contact for the openclaw agent. + - This message could be something allong the lines of "Create new note for Job 'Y' say 'blah blah blah'" This would then send that message to a Molty which would then create a new task (Specifically in l Lani's tasks list) to-do whatever ROD had sent to the agent. + +##### Design + + Dashboard Home screen will have 5 "tabs" at the top of the screen + + - Overview (This will have all users coloums and tasks) + - Lani + - Kirra + - Ayron + - ROD + + + In overview tab there will be names (Lani, kirra, Aryon, ROD) beside each name will be a circular "+" icon to add tasks to each user. just below it. Each name will have a coloum with cards + These cards will be tasks for each user. + The cards will include + - the task that needs to be done (So a description) + - Which user added it (As each user wil have to log into their account) + - The time and date it was added. + Thats pretty much the sum of these cards -- its a to-do list basically or a tasks list diff --git a/data.jsx b/data.jsx new file mode 100644 index 0000000..4eaf81e --- /dev/null +++ b/data.jsx @@ -0,0 +1,123 @@ +// Seed data for Dashy prototype + +const SEED_TASKS = [ + // Lani + { id: 't1', title: 'Call back Mrs. Patel re: Hilux service quote', + description: 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.', + assignee: 'lani', addedBy: 'rod', priority: 'high', source: 'imessage', + addedAt: '2026-05-08T08:42:00', status: 'open', + tags: ['quote'] }, + { id: 't2', title: 'Email #3814 → Workorder #2207', + description: 'Auto-converted from inbox. Tell customer ETA of Friday.', + assignee: 'lani', addedBy: 'system', priority: 'med', source: 'email', + addedAt: '2026-05-08T07:15:00', status: 'open', + tags: ['WO #2207'] }, + { id: 't3', title: 'Reorder coolant — 5L × 4', + description: 'Stock card ran red on the morning sweep.', + assignee: 'lani', addedBy: 'kirra', priority: 'low', source: 'manual', + addedAt: '2026-05-07T16:02:00', status: 'open' }, + { id: 't4', title: 'Form response from "K. Wynne" auto-switched to Billing', + description: 'Originally captured as Service Booking — heads up.', + assignee: 'lani', addedBy: 'system', priority: 'med', source: 'automation', + addedAt: '2026-05-08T09:50:00', status: 'billing', + tags: ['form'] }, + + // Kirra + { id: 't5', title: 'Diagnose intermittent misfire — Camry, WO #2199', + description: 'Cust says it stutters between 60–80km/h once warm.', + assignee: 'kirra', addedBy: 'rod', priority: 'high', source: 'manual', + addedAt: '2026-05-08T07:50:00', status: 'open', + tags: ['WO #2199'] }, + { id: 't6', title: 'Sign off on Ayron\'s brake job — Forester', + description: 'Final torque check + road test before pickup at 3pm.', + assignee: 'kirra', addedBy: 'ayron', priority: 'med', source: 'manual', + addedAt: '2026-05-08T09:05:00', status: 'open' }, + { id: 't7', title: 'WO #2188 auto-marked Unsuccessful', + description: 'Customer no-show twice — please review and decide on next step.', + assignee: 'kirra', addedBy: 'system', priority: 'high', source: 'automation', + addedAt: '2026-05-08T06:00:00', status: 'unsuccessful', + tags: ['WO #2188'] }, + + // Ayron + { id: 't8', title: 'Replace front pads + rotors — Forester', + description: 'Parts arrived yesterday. Bay 2 from 10am.', + assignee: 'ayron', addedBy: 'kirra', priority: 'med', source: 'manual', + addedAt: '2026-05-08T08:00:00', status: 'open', + tags: ['WO #2201'] }, + { id: 't9', title: 'Tidy bay 3 + sweep before lunch', + description: '', + assignee: 'ayron', addedBy: 'rod', priority: 'low', source: 'imessage', + addedAt: '2026-05-08T09:30:00', status: 'open' }, + { id: 't10', title: 'Pickup parts from Repco @ 11:30', + description: 'Two boxes for WO #2199 + an oil filter for stock.', + assignee: 'ayron', addedBy: 'lani', priority: 'med', source: 'manual', + addedAt: '2026-05-08T08:20:00', status: 'open' }, + + // ROD + { id: 't11', title: 'Approve quote on Job #2207 ($1,840)', + description: 'Lani flagged this — needs your sign-off before sending.', + assignee: 'rod', addedBy: 'lani', priority: 'high', source: 'manual', + addedAt: '2026-05-08T09:12:00', status: 'open', + tags: ['quote', 'WO #2207'] }, + { id: 't12', title: 'Review weekly automation report', + description: '14 tasks created from email, 6 from iMessage, 2 form re-routes.', + assignee: 'rod', addedBy: 'system', priority: 'low', source: 'automation', + addedAt: '2026-05-08T06:00:00', status: 'open' }, +]; + +const SEED_AUDIT = [ + { id: 'a1', at: '2026-05-08T09:50:00', actor: 'system', action: 'form_rerouted', + summary: 'Form from K. Wynne auto-switched: Service Booking → Billing form', + target: 't4' }, + { id: 'a2', at: '2026-05-08T09:30:00', actor: 'rod', action: 'task_created', + summary: 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage', + target: 't9' }, + { id: 'a3', at: '2026-05-08T09:12:00', actor: 'lani', action: 'task_assigned', + summary: 'Assigned "Approve quote on Job #2207" to ROD', + target: 't11' }, + { id: 'a4', at: '2026-05-08T09:05:00', actor: 'ayron', action: 'task_moved', + summary: 'Moved "Sign off on brake job" from Ayron → Kirra', + target: 't6' }, + { id: 'a5', at: '2026-05-08T08:42:00', actor: 'rod', action: 'task_created', + summary: 'Created task "Call back Mrs. Patel" for Lani via iMessage', + target: 't1' }, + { id: 'a6', at: '2026-05-08T08:20:00', actor: 'lani', action: 'task_created', + summary: 'Created task "Pickup parts from Repco" for Ayron', + target: 't10' }, + { id: 'a7', at: '2026-05-08T08:00:00', actor: 'kirra', action: 'task_created', + summary: 'Created task "Replace front pads + rotors" for Ayron', + target: 't8' }, + { id: 'a8', at: '2026-05-08T07:50:00', actor: 'rod', action: 'task_edited', + summary: 'Edited priority on "Diagnose intermittent misfire" — Med → High', + target: 't5' }, + { id: 'a9', at: '2026-05-08T07:15:00', actor: 'system', action: 'email_converted', + summary: 'Email #3814 converted to Workorder #2207 (assigned to Lani)', + target: 't2' }, + { id: 'a10', at: '2026-05-08T06:00:00', actor: 'system', action: 'task_unsuccessful', + summary: 'WO #2188 auto-marked Unsuccessful after 2 missed bookings', + target: 't7' }, + { id: 'a11', at: '2026-05-07T16:02:00', actor: 'kirra', action: 'task_created', + summary: 'Created task "Reorder coolant" for Lani', + target: 't3' }, + { id: 'a12', at: '2026-05-07T15:30:00', actor: 'rod', action: 'login', + summary: 'Signed in from MacBook · Christchurch', + target: null }, +]; + +// Per-task audit slices +const TASK_AUDIT = { + t1: [ + { at: '2026-05-08T08:42:00', actor: 'rod', action: 'created via iMessage', + detail: '"Hey, create new task for Lani — call back Mrs. Patel about the Hilux quote, she wants confirmation before she books"' }, + { at: '2026-05-08T08:43:00', actor: 'system', action: 'priority set to High', detail: 'Inferred from "wants confirmation"' }, + { at: '2026-05-08T08:45:00', actor: 'lani', action: 'opened', detail: '' }, + ], + t11: [ + { at: '2026-05-08T09:12:00', actor: 'lani', action: 'assigned to ROD', detail: 'flagged for sign-off before send' }, + { at: '2026-05-08T09:14:00', actor: 'lani', action: 'added note', detail: 'Customer wants the work done before Mother\'s Day weekend.' }, + ], +}; + +window.SEED_TASKS = SEED_TASKS; +window.SEED_AUDIT = SEED_AUDIT; +window.TASK_AUDIT = TASK_AUDIT; diff --git a/db.js b/db.js new file mode 100644 index 0000000..c578994 --- /dev/null +++ b/db.js @@ -0,0 +1,314 @@ +// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM) +// Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS, +// persisted to localStorage on every write, and exposed as window.DashyDB. + +window.DashyDB = (function () { + const LS_KEY = 'dashy.db.v1'; + let db = null; + let SQL = null; + const listeners = new Set(); + let ready = false; + const readyWaiters = []; + + // -- schema ----------------------------------------------------------- + const SCHEMA = ` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + hue INTEGER NOT NULL, + initials TEXT NOT NULL, + email TEXT, + phone TEXT, + photo TEXT, + password_hash TEXT, + account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')), + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + assignee_id TEXT NOT NULL REFERENCES users(id), + added_by TEXT NOT NULL, + priority TEXT NOT NULL CHECK (priority IN ('low','med','high')), + source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')), + status TEXT NOT NULL DEFAULT 'open', + added_at TEXT NOT NULL, + due_at TEXT, + reminder_at TEXT + ); + CREATE INDEX idx_tasks_assignee ON tasks(assignee_id); + CREATE INDEX idx_tasks_status ON tasks(status); + + CREATE TABLE task_tags ( + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (task_id, tag) + ); + + CREATE TABLE task_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + author_id TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + at TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + summary TEXT NOT NULL, + target TEXT + ); + CREATE INDEX idx_audit_at ON audit_log(at); + + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id), + device TEXT NOT NULL, + location TEXT, + last_active TEXT NOT NULL + ); + `; + + function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); } + + function persist() { + try { + const bytes = db.export(); + const b64 = btoa(String.fromCharCode(...bytes)); + localStorage.setItem(LS_KEY, b64); + } catch(e) { console.warn('DashyDB persist failed', e); } + bumpListeners(); + } + + function loadFromLS() { + const b64 = localStorage.getItem(LS_KEY); + if (!b64) return null; + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr; + } + + function seed() { + db.exec('BEGIN'); + USERS.forEach(u => { + const isAdmin = u.id === 'rod' || u.id === 'ayron'; + db.run( + 'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)', + [u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184', + 'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard'] + ); + }); + SEED_TASKS.forEach(t => { + db.run( + 'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)', + [t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt] + ); + (t.tags || []).forEach(tag => { + db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]); + }); + }); + SEED_AUDIT.forEach(a => { + db.run( + 'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)', + [a.id, a.at, a.actor, a.action, a.summary, a.target] + ); + }); + // sample notes + db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')"); + db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')"); + db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')"); + db.exec('COMMIT'); + } + + async function init() { + SQL = await window.initSqlJs({ + locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f + }); + + const existing = loadFromLS(); + if (existing) { + db = new SQL.Database(existing); + // migration: add account_type if missing + try { + const cols = query("PRAGMA table_info(users)").map(c => c.name); + if (!cols.includes('account_type')) { + db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'"); + db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')"); + persist(); + } + } catch(e) { console.warn('migration failed', e); } + } else { + db = new SQL.Database(); + db.exec(SCHEMA); + seed(); + persist(); + } + ready = true; + readyWaiters.splice(0).forEach(fn => fn()); + bumpListeners(); + } + + function whenReady() { + if (ready) return Promise.resolve(); + return new Promise(r => readyWaiters.push(r)); + } + + // -- query helpers ---------------------------------------------------- + function rowsFrom(stmt) { + const out = []; + while (stmt.step()) out.push(stmt.getAsObject()); + stmt.free(); + return out; + } + + function query(sql, params = []) { + if (!db) return []; + const stmt = db.prepare(sql); + if (params.length) stmt.bind(params); + return rowsFrom(stmt); + } + + function exec(sql, params = []) { + if (!db) return; + db.run(sql, params); + persist(); + } + + // -- domain ops ------------------------------------------------------- + function listTasks() { + const rows = query(` + SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv + FROM tasks t + LEFT JOIN task_tags tt ON tt.task_id = t.id + GROUP BY t.id + ORDER BY t.added_at DESC + `); + return rows.map(r => ({ + id: r.id, title: r.title, description: r.description, + assignee: r.assignee_id, addedBy: r.added_by, + priority: r.priority, source: r.source, status: r.status, + addedAt: r.added_at, + tags: r.tags_csv ? r.tags_csv.split('|') : [] + })); + } + + function listAudit() { + return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC'); + } + + function listUsers() { + return query('SELECT * FROM users ORDER BY rowid ASC'); + } + + function moveTask(taskId, toUserId, by) { + exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]); + addAudit({ + actor: by, action: 'task_moved', + summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name, + target: taskId + }); + } + + function setPriority(taskId, p) { + exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]); + } + + function createTask(t) { + exec( + 'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)', + [t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt] + ); + (t.tags || []).forEach(tag => { + db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]); + }); + persist(); + } + + function addAudit(row) { + const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5); + exec( + 'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)', + [id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null] + ); + } + + function updateUser(userId, edits) { + const fields = []; const vals = []; + if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); } + if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); } + if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); } + if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); } + if (!fields.length) return; + vals.push(userId); + exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals); + } + + function createUser(u) { + const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5)); + const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase(); + const hue = u.hue || Math.floor(Math.random() * 360); + exec( + 'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)', + [id, u.name, u.role || 'Team member', hue, initials, + u.email || (id + '@murchison-auto.co'), u.phone || '', + u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)] + ); + return id; + } + + function deleteUser(userId) { + // reassign any tasks to ROD before delete + exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]); + exec('DELETE FROM users WHERE id = ?', [userId]); + } + + function tableNames() { + return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name); + } + + function tableInfo(name) { + return query(`PRAGMA table_info(${name})`); + } + + function rowCount(name) { + const r = query(`SELECT COUNT(*) AS c FROM ${name}`); + return r[0] ? r[0].c : 0; + } + + function exportFile() { + const bytes = db.export(); + return new Blob([bytes], { type: 'application/x-sqlite3' }); + } + + function reset() { + localStorage.removeItem(LS_KEY); + db = new SQL.Database(); + db.exec(SCHEMA); + seed(); + persist(); + } + + function rawExec(sql) { + // returns array of {columns, values} for SELECTs, or empty for writes + const res = db.exec(sql); + persist(); + return res; + } + + function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } + + return { + init, whenReady, query, exec, rawExec, + listTasks, listAudit, listUsers, + createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit, + tableNames, tableInfo, rowCount, exportFile, reset, subscribe, + get isReady() { return ready; }, + }; +})(); diff --git a/screens.jsx b/screens.jsx new file mode 100644 index 0000000..c8d4ba7 --- /dev/null +++ b/screens.jsx @@ -0,0 +1,1057 @@ +// Screens for Dashy + +function LoginScreen({ onLogin }) { + const [pickedId, setPickedId] = React.useState('rod'); + return ( +
+
+
+ + Dashy +
+

Pick up where you left off.

+

Sign in to your team workspace · murchison-auto

+ +
+ {USERS.map(u => ( + + ))} +
+ + + + + +

+ ⌘ ⏎ to submit · e.preventDefault()}>Forgot password +

+
+ +
+
04 May → 08 May
+
+ + + + +
+
v0.1 · build 26.05.08
+
+
+ ); +} + +function Stat({ n, label }) { + return ( +
+
{n}
+
{label}
+
+ ); +} + +function BrandMark({ size = 22 }) { + return ( + + ); +} + +function TopBar({ me, isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, onDB }) { + return ( +
+
+ Dashy + + murchison-auto +
+ + + +
+ + + + + {isAdmin && ( + + + + )} + {isAdmin && ( + + + + + + + + )} + +
+
+ ); +} + +function Tab({ id, label, tab, setTab, user }) { + const active = tab === id; + return ( + + ); +} + +function HeadsUp({ items, onDismiss, onOpenTask }) { + if (!items.length) return null; + return ( +
+ {items.map(it => ( +
+ + {it.kind === 'unsuccessful' ? '!' : it.kind === 'billing' ? '⇄' : '✦'} + +
+
{it.title}
+
{it.sub}
+
+ + +
+ ))} +
+ ); +} + +function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask }) { + const byUser = Object.fromEntries(USERS.map(u => [u.id, []])); + tasks.forEach(t => { if (byUser[t.assignee]) byUser[t.assignee].push(t); }); + const [draggingTask, setDraggingTask] = React.useState(null); + const [dragOverCol, setDragOverCol] = React.useState(null); + return ( +
+ {USERS.map(u => ( + onAddFor(u.id)} + density={density} + dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id} + onDragOver={(uid) => setDragOverCol(uid)} + onDragLeave={() => setDragOverCol(prev => prev === u.id ? null : prev)} + onDragStartCard={(t) => setDraggingTask(t)} + onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); }} + draggingId={draggingTask && draggingTask.id} + onDropTask={(toId) => { + if (draggingTask && draggingTask.assignee !== toId) { + onMoveTask && onMoveTask(draggingTask.id, toId); + } + setDraggingTask(null); setDragOverCol(null); + }} + /> + ))} +
+ ); +} + +function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId }) { + return ( +
{ e.preventDefault(); onDragOver && onDragOver(user.id); }} + onDragLeave={(e) => { onDragLeave && onDragLeave(user.id); }} + onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(user.id); }} + > +
+
+ +
+

{user.name}

+ {user.role} +
+
+
+ {tasks.length} + +
+
+
+ {tasks.length === 0 && ( +
+ — inbox zero — +
+ )} + {tasks.map(t => ( + + ))} +
+
+ ); +} + +function UserScreen({ user, tasks, onOpen, onAddFor, density }) { + const mine = tasks.filter(t => t.assignee === user.id); + const open = mine.filter(t => t.status === 'open'); + const flagged = mine.filter(t => t.status === 'unsuccessful' || t.status === 'billing'); + return ( +
+
+ +
+

{user.name}

+

{user.role} · {mine.length} tasks

+
+
+ +
+
+ + {flagged.length > 0 && ( +
+
+ {flagged.map(t => )} +
+
+ )} + +
+
+ {open.map(t => )} +
+
+
+ ); +} + +function Section({ title, sub, children }) { + return ( +
+
+

{title}

+ {sub && {sub}} +
+ {children} +
+ ); +} + +function AddTaskModal({ open, onClose, onSubmit, defaultAssignee, me }) { + const [title, setTitle] = React.useState(''); + const [desc, setDesc] = React.useState(''); + const [assignee, setAssignee] = React.useState(defaultAssignee || 'lani'); + const [priority, setPriority] = React.useState('med'); + + React.useEffect(() => { if (open) setAssignee(defaultAssignee || 'lani'); }, [open, defaultAssignee]); + React.useEffect(() => { if (open) { setTitle(''); setDesc(''); setPriority('med'); } }, [open]); + + if (!open) return null; + const submit = (e) => { + e && e.preventDefault(); + if (!title.trim()) return; + onSubmit({ title: title.trim(), description: desc.trim(), assignee, priority }); + }; + + return ( + +
+ + +