feat: project metadata fields (client, job#, repo) with inline editing + time calc audit

This commit is contained in:
Vadym Samoilenko 2026-03-26 15:08:14 +00:00
parent 9ebe5a92fa
commit 4d69a4ac64
8 changed files with 266 additions and 37 deletions

View 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)"
]
}
}

View 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")

View file

@ -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")

View file

@ -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"]),

View file

@ -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

View file

@ -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}

View file

@ -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;

View file

@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 };
})();