feat: project metadata fields (client, job#, repo) with inline editing + time calc audit
This commit is contained in:
parent
9ebe5a92fa
commit
4d69a4ac64
8 changed files with 266 additions and 37 deletions
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh optical-dev:*)",
|
||||
"mcp__21st-dev-magic__21st_magic_component_inspiration",
|
||||
"Bash(node --check /Volumes/SSD/Projects/Oliver/cc-dashboard/src/static/js/pages/keys.js)",
|
||||
"Bash(CC_API_KEY=cc_YcRD4q8NqZanghwFA9z7b-ZgXf57vgBcKpjhlOM_Jd0 CC_SERVER=https://optical-dev.oliver.solutions/cc-dashboard CC_ROOT_PATH=/Volumes/SSD/Projects/Oliver python3 /Users/aimpress/.claude/cc-collector.py 2>&1)",
|
||||
"Bash(bash deploy.sh)",
|
||||
"Bash(rm -f ~/.claude/.cc-collector-state.json)",
|
||||
"Bash(CC_API_KEY=cc_YcRD4q8NqZanghwFA9z7b-ZgXf57vgBcKpjhlOM_Jd0 CC_SERVER=https://optical-dev.oliver.solutions/cc-dashboard CC_ROOT_PATH=/Volumes/SSD/Projects/Oliver CC_LOOKBACK=8760 python3 /Users/aimpress/.claude/cc-collector.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
25
alembic/versions/0002_project_metadata.py
Normal file
25
alembic/versions/0002_project_metadata.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""add client, job_number, repo_url to projects
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-03-26
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("projects", sa.Column("client", sa.String(255), nullable=False, server_default=""))
|
||||
op.add_column("projects", sa.Column("job_number", sa.String(100), nullable=False, server_default=""))
|
||||
op.add_column("projects", sa.Column("repo_url", sa.String(500), nullable=False, server_default=""))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("projects", "repo_url")
|
||||
op.drop_column("projects", "job_number")
|
||||
op.drop_column("projects", "client")
|
||||
|
|
@ -57,6 +57,9 @@ class Project(Base):
|
|||
slug: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
root_path: Mapped[str] = mapped_column(String(500), default="")
|
||||
client: Mapped[str] = mapped_column(String(255), default="")
|
||||
job_number: Mapped[str] = mapped_column(String(100), default="")
|
||||
repo_url: Mapped[str] = mapped_column(String(500), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="projects")
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ async def projects_overview(
|
|||
proj_data[pid] = {
|
||||
"project_id": pid,
|
||||
"display_name": project.display_name,
|
||||
"client": project.client or "",
|
||||
"job_number": project.job_number or "",
|
||||
"repo_url": project.repo_url or "",
|
||||
"total_hours": 0.0,
|
||||
"session_count": 0,
|
||||
"days": set(),
|
||||
|
|
@ -143,6 +146,9 @@ async def projects_overview(
|
|||
ProjectHours(
|
||||
project_id=v["project_id"],
|
||||
display_name=v["display_name"],
|
||||
client=v["client"],
|
||||
job_number=v["job_number"],
|
||||
repo_url=v["repo_url"],
|
||||
total_hours=round(v["total_hours"], 2),
|
||||
session_count=v["session_count"],
|
||||
working_days=len(v["days"]),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ async def update_project(
|
|||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if "display_name" in body:
|
||||
project.display_name = str(body["display_name"])[:255]
|
||||
if "client" in body:
|
||||
project.client = str(body["client"])[:255]
|
||||
if "job_number" in body:
|
||||
project.job_number = str(body["job_number"])[:100]
|
||||
if "repo_url" in body:
|
||||
project.repo_url = str(body["repo_url"])[:500]
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
return project
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ class ProjectHours(BaseModel):
|
|||
session_count: int
|
||||
working_days: int
|
||||
last_active: date | None
|
||||
client: str = ""
|
||||
job_number: str = ""
|
||||
repo_url: str = ""
|
||||
|
||||
|
||||
class DailyPoint(BaseModel):
|
||||
|
|
@ -179,6 +182,9 @@ class ProjectOut(BaseModel):
|
|||
slug: str
|
||||
display_name: str
|
||||
root_path: str
|
||||
client: str = ""
|
||||
job_number: str = ""
|
||||
repo_url: str = ""
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
|
|||
|
|
@ -360,6 +360,27 @@ td .badge, .badge {
|
|||
.badge-success { background: rgba(34,197,94,.12); color: var(--success); }
|
||||
.badge-danger { background: rgba(239,68,68,.12); color: var(--danger); }
|
||||
|
||||
/* ── Inline editable cells ─────────────────────────── */
|
||||
.editable-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.editable-cell:hover { background: var(--surface2); }
|
||||
.editable-cell .edit-hint {
|
||||
opacity: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transition: opacity .15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editable-cell:hover .edit-hint { opacity: 1; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -1,48 +1,197 @@
|
|||
const ProjectsPage = (() => {
|
||||
let _projects = [];
|
||||
|
||||
async function render(container) {
|
||||
container.innerHTML = `<div class="page"><div class="loading">Loading projects…</div></div>`;
|
||||
try {
|
||||
const projects = await Api.get('/api/dashboard/projects');
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="section-header">
|
||||
<h2>Projects</h2>
|
||||
<span style="color:var(--text-muted);font-size:13px">${projects.length} project${projects.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Total Hours</th>
|
||||
<th>Sessions</th>
|
||||
<th>Working Days</th>
|
||||
<th>Last Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${projects.map(p => `
|
||||
<tr>
|
||||
<td><strong>${p.display_name}</strong></td>
|
||||
<td><span class="badge badge-accent">${p.total_hours.toFixed(1)}h</span></td>
|
||||
<td>${p.session_count}</td>
|
||||
<td>${p.working_days}</td>
|
||||
<td style="color:var(--text-muted)">${p.last_active || '—'}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick="App.navigate('project', '${p.project_id}')">View →</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
_projects = await Api.get('/api/dashboard/projects');
|
||||
_renderTable(container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="page"><div class="empty"><div class="big">⚠️</div>${e.message}</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderTable(container) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="section-header">
|
||||
<h2>Projects</h2>
|
||||
<span style="color:var(--text-muted);font-size:13px">${_projects.length} project${_projects.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div class="table-card" style="overflow-x:auto">
|
||||
<table style="min-width:900px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Client</th>
|
||||
<th>OMG Job #</th>
|
||||
<th>Repo</th>
|
||||
<th style="text-align:right">Hours</th>
|
||||
<th style="text-align:right">Sessions</th>
|
||||
<th style="text-align:right">Days</th>
|
||||
<th>Last Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${_projects.map(p => _rowHtml(p)).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
_bindEvents(container);
|
||||
}
|
||||
|
||||
function _rowHtml(p) {
|
||||
const repoBtn = p.repo_url
|
||||
? `<a href="${_esc(p.repo_url)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm repo-link" data-id="${p.project_id}" style="font-size:11px;gap:4px">
|
||||
<span>↗</span><span class="repo-label">${_repoLabel(p.repo_url)}</span>
|
||||
</a>
|
||||
<button class="btn btn-ghost btn-sm repo-edit-btn" data-id="${p.project_id}" title="Edit repo URL" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✎</button>`
|
||||
: `<button class="btn btn-ghost btn-sm repo-edit-btn" data-id="${p.project_id}" style="font-size:11px;color:var(--text-muted)">+ Add Repo</button>`;
|
||||
|
||||
return `
|
||||
<tr data-id="${p.project_id}">
|
||||
<td>
|
||||
<span class="editable-cell" data-field="display_name" data-id="${p.project_id}" title="Click to rename">
|
||||
<strong>${_esc(p.display_name)}</strong>
|
||||
<span class="edit-hint">✎</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="editable-cell" data-field="client" data-id="${p.project_id}" title="Click to edit">
|
||||
${p.client ? _esc(p.client) : '<span style="color:var(--text-muted);font-size:12px">—</span>'}
|
||||
<span class="edit-hint">✎</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="editable-cell" data-field="job_number" data-id="${p.project_id}" title="Click to edit">
|
||||
${p.job_number ? _esc(p.job_number) : '<span style="color:var(--text-muted);font-size:12px">—</span>'}
|
||||
<span class="edit-hint">✎</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="repo-cell" data-id="${p.project_id}">
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
${repoBtn}
|
||||
</div>
|
||||
</td>
|
||||
<td style="text-align:right"><span class="badge badge-accent">${p.total_hours.toFixed(1)}h</span></td>
|
||||
<td style="text-align:right;color:var(--text-muted)">${p.session_count}</td>
|
||||
<td style="text-align:right;color:var(--text-muted)">${p.working_days}</td>
|
||||
<td style="color:var(--text-muted);font-size:12px">${p.last_active || '—'}</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm" onclick="App.navigate('project', '${p.project_id}')">View →</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function _repoLabel(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.hostname + u.pathname).replace(/\/$/, '').replace('www.', '');
|
||||
} catch {
|
||||
return url.slice(0, 30);
|
||||
}
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function _bindEvents(container) {
|
||||
// Inline editable cells
|
||||
container.querySelectorAll('.editable-cell').forEach(cell => {
|
||||
cell.onclick = () => _startEdit(cell);
|
||||
});
|
||||
|
||||
// Repo edit buttons
|
||||
container.querySelectorAll('.repo-edit-btn').forEach(btn => {
|
||||
btn.onclick = () => _editRepo(btn.dataset.id, container);
|
||||
});
|
||||
}
|
||||
|
||||
function _startEdit(cell) {
|
||||
if (cell.querySelector('input')) return; // already editing
|
||||
const field = cell.dataset.field;
|
||||
const id = cell.dataset.id;
|
||||
const proj = _projects.find(p => p.project_id === id);
|
||||
const current = proj ? (proj[field] || '') : '';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = current;
|
||||
input.style.cssText = 'background:var(--surface2);border:1px solid var(--accent);border-radius:4px;color:var(--text);padding:3px 7px;font-size:13px;font-family:inherit;width:100%;min-width:120px;outline:none';
|
||||
|
||||
cell.innerHTML = '';
|
||||
cell.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const save = async () => {
|
||||
const val = input.value.trim();
|
||||
if (val === current) { _renderTable(cell.closest('.page').parentElement); return; }
|
||||
try {
|
||||
await Api.patch('/api/projects/' + id, { [field]: val });
|
||||
const p = _projects.find(p => p.project_id === id);
|
||||
if (p) p[field] = val;
|
||||
} catch (e) {
|
||||
alert('Save failed: ' + e.message);
|
||||
}
|
||||
_renderTable(cell.closest('.page').parentElement);
|
||||
};
|
||||
|
||||
input.onblur = save;
|
||||
input.onkeydown = e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
||||
if (e.key === 'Escape') { _renderTable(cell.closest('.page').parentElement); }
|
||||
};
|
||||
}
|
||||
|
||||
function _editRepo(id, container) {
|
||||
const proj = _projects.find(p => p.project_id === id);
|
||||
const current = proj ? (proj.repo_url || '') : '';
|
||||
|
||||
// Build a small inline input in the repo cell
|
||||
const repoCell = container.querySelector(`.repo-cell[data-id="${id}"]`);
|
||||
if (!repoCell) return;
|
||||
|
||||
const div = repoCell.querySelector('div');
|
||||
div.innerHTML = '';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'url';
|
||||
input.value = current;
|
||||
input.placeholder = 'https://github.com/org/repo';
|
||||
input.style.cssText = 'background:var(--surface2);border:1px solid var(--accent);border-radius:4px;color:var(--text);padding:3px 7px;font-size:12px;font-family:inherit;width:180px;outline:none';
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.textContent = 'Save';
|
||||
saveBtn.className = 'btn btn-primary btn-sm';
|
||||
saveBtn.style.fontSize = '11px';
|
||||
|
||||
div.appendChild(input);
|
||||
div.appendChild(saveBtn);
|
||||
input.focus();
|
||||
|
||||
const save = async () => {
|
||||
const val = input.value.trim();
|
||||
try {
|
||||
await Api.patch('/api/projects/' + id, { repo_url: val });
|
||||
if (proj) proj.repo_url = val;
|
||||
} catch (e) {
|
||||
alert('Save failed: ' + e.message);
|
||||
}
|
||||
_renderTable(container);
|
||||
};
|
||||
|
||||
saveBtn.onclick = save;
|
||||
input.onkeydown = e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||
if (e.key === 'Escape') { _renderTable(container); }
|
||||
};
|
||||
}
|
||||
|
||||
return { render };
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue