feat: admin set password for users + fix dashboard cache error
Admin Set Password: - New setPassword adminProcedure in user router (Argon2 hashing) - Audit log: "Password reset by admin" (no password value logged) - UI: per-user "Password" button with key icon in User Management - Modal: new password + confirm, min 8 chars, mismatch validation - Success toast + auto-close on completion Dashboard fix: - Corrupted .next cache causing "Cannot find module worker.js" - Fixed by clearing .next cache and restarting dev server Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { AnimatedModal } from "~/components/ui/AnimatedModal.js";
|
||||||
|
import { SuccessToast } from "~/components/ui/SuccessToast.js";
|
||||||
import { FilterChips } from "~/components/ui/FilterChips.js";
|
import { FilterChips } from "~/components/ui/FilterChips.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||||
@@ -90,6 +92,11 @@ export function UsersClient() {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
const [roleFilter, setRoleFilter] = useState<SystemRole | "">("");
|
||||||
|
const [passwordTarget, setPasswordTarget] = useState<{ userId: string; userName: string } | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
|
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -166,6 +173,53 @@ export function UsersClient() {
|
|||||||
onError: (err) => setActionError(err.message),
|
onError: (err) => setActionError(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setPasswordMutation = trpc.user.setPassword.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setPasswordSuccess(true);
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setPasswordError(null);
|
||||||
|
setTimeout(() => {
|
||||||
|
setPasswordTarget(null);
|
||||||
|
setPasswordSuccess(false);
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
onError: (err) => setPasswordError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function openSetPassword(user: UserRow) {
|
||||||
|
setPasswordTarget({ userId: user.id, userName: user.name ?? user.email });
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setPasswordError(null);
|
||||||
|
setPasswordSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSetPassword() {
|
||||||
|
setPasswordTarget(null);
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
setPasswordError(null);
|
||||||
|
setPasswordSuccess(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetPassword() {
|
||||||
|
if (!passwordTarget) return;
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
setPasswordError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasswordError(null);
|
||||||
|
await setPasswordMutation.mutateAsync({
|
||||||
|
userId: passwordTarget.userId,
|
||||||
|
password: newPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openEdit(user: UserRow) {
|
function openEdit(user: UserRow) {
|
||||||
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
|
||||||
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
const overrides = user.permissionOverrides as PermissionOverrides | null;
|
||||||
@@ -291,7 +345,8 @@ export function UsersClient() {
|
|||||||
updateRoleMutation.isPending ||
|
updateRoleMutation.isPending ||
|
||||||
setPermissionsMutation.isPending ||
|
setPermissionsMutation.isPending ||
|
||||||
resetPermissionsMutation.isPending ||
|
resetPermissionsMutation.isPending ||
|
||||||
createUserMutation.isPending;
|
createUserMutation.isPending ||
|
||||||
|
setPasswordMutation.isPending;
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
setSearch("");
|
setSearch("");
|
||||||
@@ -474,13 +529,26 @@ export function UsersClient() {
|
|||||||
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
{new Date(user.createdAt).toLocaleDateString("en-GB")}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<button
|
<div className="flex items-center justify-end gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => openEdit(user)}
|
type="button"
|
||||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
onClick={() => openSetPassword(user)}
|
||||||
>
|
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium"
|
||||||
Edit
|
title="Set password"
|
||||||
</button>
|
>
|
||||||
|
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(user)}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -488,6 +556,81 @@ export function UsersClient() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Set Password Modal */}
|
||||||
|
<AnimatedModal open={!!passwordTarget} onClose={closeSetPassword} maxWidth="max-w-md">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Set Password for {passwordTarget?.userName}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
{passwordError && (
|
||||||
|
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||||
|
{passwordError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{newPassword.length > 0 && newPassword.length < 8 && (
|
||||||
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
{8 - newPassword.length} more character{8 - newPassword.length !== 1 ? "s" : ""} needed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Repeat password"
|
||||||
|
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{confirmPassword.length > 0 && newPassword !== confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||||
|
Passwords do not match
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeSetPassword}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSetPassword()}
|
||||||
|
disabled={setPasswordMutation.isPending || newPassword.length < 8 || newPassword !== confirmPassword}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{setPasswordMutation.isPending ? "Saving..." : "Set Password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
|
||||||
|
<SuccessToast show={passwordSuccess} message="Password updated successfully" />
|
||||||
|
|
||||||
{/* Create User Modal */}
|
{/* Create User Modal */}
|
||||||
{createState && (
|
{createState && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
|||||||
@@ -126,6 +126,41 @@ export const userRouter = createTRPCRouter({
|
|||||||
return user;
|
return user;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setPassword: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = await ctx.db.user.findUniqueOrThrow({
|
||||||
|
where: { id: input.userId },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { hash } = await import("@node-rs/argon2");
|
||||||
|
const passwordHash = await hash(input.password);
|
||||||
|
|
||||||
|
await ctx.db.user.update({
|
||||||
|
where: { id: input.userId },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
void createAuditEntry({
|
||||||
|
db: ctx.db,
|
||||||
|
entityType: "User",
|
||||||
|
entityId: user.id,
|
||||||
|
entityName: `${user.name} (${user.email})`,
|
||||||
|
action: "UPDATE",
|
||||||
|
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||||
|
source: "ui",
|
||||||
|
summary: "Password reset by admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
updateRole: adminProcedure
|
updateRole: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -1,61 +1,19 @@
|
|||||||
# Dashboard Widget Filter System — Plan
|
# Admin Set Password — Plan
|
||||||
|
|
||||||
## Anforderungsanalyse
|
## Anforderungsanalyse
|
||||||
|
|
||||||
**Was:** Einheitliches Filter-System fuer alle Dashboard-Widgets. Filter-Logik soll geteilt werden statt pro Widget dupliziert.
|
**Was:** Admins sollen im User-Management das Passwort fuer beliebige User setzen/zuruecksetzen koennen.
|
||||||
|
|
||||||
**Anforderungen pro Widget:**
|
**Ist-Zustand:**
|
||||||
| Widget | Filter benoetigt |
|
- `user.create` Mutation hat Passwort-Support (Argon2 Hashing via `@node-rs/argon2`)
|
||||||
|--------|-----------------|
|
- Kein `setPassword` oder `resetPassword` Mutation fuer bestehende User
|
||||||
| **Project Health** | Projektname (Suche), Client |
|
- UsersClient hat Passwort nur im Create-Formular, nicht im Edit-Bereich
|
||||||
| **Budget Forecast** | Projektname (Suche), Client |
|
|
||||||
| **Chargeability Overview** | Country, Role/Chapter |
|
|
||||||
| **Skill Gap** | (optional: Skill-Suche) |
|
|
||||||
| **Resource Table** | Hat bereits: Chapter-Filter |
|
|
||||||
| **Project Table** | Hat bereits: Suche + Status-Filter |
|
|
||||||
| **Peak Times** | Hat bereits: Granularity + GroupBy |
|
|
||||||
|
|
||||||
**Design-Prinzip:** Ein shared `<WidgetFilterBar>` Komponente die verschiedene Filter-Typen als deklarative Config akzeptiert. Filter-State wird via `onConfigChange` im Widget-Config persistiert (bereits vorhanden).
|
**Soll-Zustand:**
|
||||||
|
- Neuer `setPassword` adminProcedure im user Router
|
||||||
---
|
- "Set Password" Button pro User im Admin-UI
|
||||||
|
- Passwort-Modal mit Eingabe + Bestaetigung
|
||||||
## Architektur
|
- Audit-Log Eintrag bei Passwort-Aenderung (ohne Passwort-Wert!)
|
||||||
|
|
||||||
### Shared Filter Component
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// Deklarative Filter-Konfiguration pro Widget
|
|
||||||
const filters: WidgetFilter[] = [
|
|
||||||
{ type: "search", key: "search", placeholder: "Filter projects..." },
|
|
||||||
{ type: "select", key: "clientId", label: "Client", options: clients },
|
|
||||||
{ type: "select", key: "countryId", label: "Country", options: countries },
|
|
||||||
{ type: "select", key: "roleId", label: "Role", options: roles },
|
|
||||||
];
|
|
||||||
|
|
||||||
<WidgetFilterBar
|
|
||||||
filters={filters}
|
|
||||||
values={config} // Aktueller Filter-State aus Widget-Config
|
|
||||||
onChange={onConfigChange} // Persistiert in localStorage via Dashboard-Layout
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter-Typen
|
|
||||||
|
|
||||||
| Typ | UI-Element | Use Cases |
|
|
||||||
|-----|-----------|-----------|
|
|
||||||
| `search` | Text-Input mit Lupe | Projektname, Ressourcenname |
|
|
||||||
| `select` | Dropdown | Client, Country, Role, Status |
|
|
||||||
| `toggle` | Checkbox | Include Proposed, Show Inactive |
|
|
||||||
|
|
||||||
### Daten-Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Widget Config (localStorage)
|
|
||||||
↓ values
|
|
||||||
WidgetFilterBar → onChange → onConfigChange → persistiert
|
|
||||||
↓ values
|
|
||||||
Widget Query (tRPC) → gefilterte Daten
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,114 +21,54 @@ Widget Query (tRPC) → gefilterte Daten
|
|||||||
|
|
||||||
| Paket | Dateien | Art der Aenderung |
|
| Paket | Dateien | Art der Aenderung |
|
||||||
|-------|---------|------------------|
|
|-------|---------|------------------|
|
||||||
| `apps/web` | `src/components/dashboard/WidgetFilterBar.tsx` | **create** — Shared Filter-Komponente |
|
| `packages/api` | `src/router/user.ts` | **edit** — `setPassword` Mutation hinzufuegen |
|
||||||
| `apps/web` | `src/hooks/useWidgetFilterOptions.ts` | **create** — Hook fuer gemeinsame Filter-Optionen (clients, countries, roles) |
|
| `apps/web` | `src/components/admin/UsersClient.tsx` | **edit** — "Set Password" Button + Modal |
|
||||||
| `apps/web` | `src/components/dashboard/widgets/ProjectHealthWidget.tsx` | **edit** — Filter integrieren |
|
|
||||||
| `apps/web` | `src/components/dashboard/widgets/BudgetForecastWidget.tsx` | **edit** — Filter integrieren |
|
|
||||||
| `apps/web` | `src/components/dashboard/widgets/ChargeabilityWidget.tsx` | **edit** — Filter integrieren |
|
|
||||||
| `apps/web` | `src/components/dashboard/widgets/SkillGapWidget.tsx` | **edit** — Optional: Skill-Suche |
|
|
||||||
| `apps/web` | `src/components/dashboard/widgets/DemandWidget.tsx` | **edit** — Optional: Client/Chapter Filter |
|
|
||||||
| `apps/web` | `src/components/dashboard/widgets/TopValueWidget.tsx` | **edit** — Optional: Chapter Filter |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task-Liste
|
## Task-Liste
|
||||||
|
|
||||||
### Phase 1: Shared Infrastructure
|
- [ ] **Task 1:** `setPassword` Mutation → `packages/api/src/router/user.ts`
|
||||||
|
- Input: `{ userId: string, password: string }` (min 8 Zeichen)
|
||||||
|
- adminProcedure (nur Admins duerfen Passwoerter setzen)
|
||||||
|
- Hash mit `@node-rs/argon2` (gleiches Pattern wie `create`)
|
||||||
|
- `db.user.update({ where: { id }, data: { passwordHash } })`
|
||||||
|
- Audit-Log: `createAuditEntry({ entityType: "User", action: "UPDATE", summary: "Password reset by admin" })`
|
||||||
|
- KEIN Passwort-Wert im Audit-Log (Sicherheit!)
|
||||||
|
|
||||||
- [ ] **Task 1:** `useWidgetFilterOptions` Hook erstellen → `src/hooks/useWidgetFilterOptions.ts`
|
- [ ] **Task 2:** UI — "Set Password" Button + Modal → `UsersClient.tsx`
|
||||||
- Cached Queries fuer Clients, Countries, Roles (alle mit `staleTime: 300_000`)
|
- Pro User-Zeile: "Set Password" Button (Schloss-Icon)
|
||||||
- Gibt `{ clients, countries, roles, chapters }` als `{ value: string, label: string }[]` zurueck
|
- Klick oeffnet AnimatedModal mit:
|
||||||
- Chapters extrahiert aus Resource-Daten oder als dedizierte Query
|
- User-Name als Titel
|
||||||
- Nur einmal pro Dashboard geladen, von allen Widgets geteilt
|
- Neues Passwort Input (min 8 Zeichen)
|
||||||
|
- Passwort bestaetigen Input
|
||||||
- [ ] **Task 2:** `WidgetFilterBar` Komponente erstellen → `src/components/dashboard/WidgetFilterBar.tsx`
|
- Validierung: Passwoerter muessen uebereinstimmen
|
||||||
```typescript
|
- Submit-Button (disabled wenn <8 Zeichen oder nicht matching)
|
||||||
interface WidgetFilter {
|
- Success: Toast "Password updated", Modal schliessen
|
||||||
type: "search" | "select" | "toggle";
|
- Error: Fehlermeldung anzeigen
|
||||||
key: string; // Config-Schluessel (z.B. "clientId")
|
|
||||||
label?: string; // Display-Label
|
|
||||||
placeholder?: string; // Fuer search/select
|
|
||||||
options?: { value: string; label: string }[]; // Fuer select
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WidgetFilterBarProps {
|
|
||||||
filters: WidgetFilter[];
|
|
||||||
values: Record<string, unknown>;
|
|
||||||
onChange: (update: Record<string, unknown>) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- Kompaktes Layout: horizontal, passt in Widget-Header-Bereich
|
|
||||||
- Kleine Inputs (text-xs, py-1) damit sie nicht zu viel Platz nehmen
|
|
||||||
- "Reset" Button wenn Filter aktiv
|
|
||||||
- Dark-Theme Support
|
|
||||||
|
|
||||||
### Phase 2: Widget Integration (parallel)
|
|
||||||
|
|
||||||
- [ ] **Task 3:** ProjectHealthWidget + Filter → `ProjectHealthWidget.tsx`
|
|
||||||
- Filter: `search` (Projektname), `select` (Client)
|
|
||||||
- Client-Daten laden via useWidgetFilterOptions
|
|
||||||
- Filtern: `rows.filter(r => matchesSearch && matchesClient)`
|
|
||||||
- Filter-State via `config.search`, `config.clientId`
|
|
||||||
|
|
||||||
- [ ] **Task 4:** BudgetForecastWidget + Filter → `BudgetForecastWidget.tsx`
|
|
||||||
- Gleiche Filter wie ProjectHealth: search + clientId
|
|
||||||
- Gleiche Logik, gleiche WidgetFilterBar Config
|
|
||||||
|
|
||||||
- [ ] **Task 5:** ChargeabilityWidget + Filter → `ChargeabilityWidget.tsx`
|
|
||||||
- Filter: `select` (Country), `select` (Role/Chapter)
|
|
||||||
- Country/Role-Daten via useWidgetFilterOptions
|
|
||||||
- Filtern ueber die Resource-Daten in der Chargeability-Antwort
|
|
||||||
- `toggle` (Include Proposed) — bereits vorhanden, in WidgetFilterBar integrieren
|
|
||||||
|
|
||||||
- [ ] **Task 6:** SkillGapWidget + Filter → `SkillGapWidget.tsx`
|
|
||||||
- Filter: `search` (Skill-Name)
|
|
||||||
- Einfache client-seitige Filterung der Skill-Liste
|
|
||||||
|
|
||||||
- [ ] **Task 7:** TopValueWidget + Filter → `TopValueWidget.tsx`
|
|
||||||
- Filter: `select` (Chapter) — bereits sortierbar, Chapter-Filter hinzufuegen
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abhaengigkeiten
|
## Abhaengigkeiten
|
||||||
|
|
||||||
```
|
- Task 1 muss vor Task 2 (API benoetigt fuer UI)
|
||||||
Task 1 (Hook) + Task 2 (WidgetFilterBar) → koennen parallel
|
- Beide Tasks koennen in einer Sequenz implementiert werden (gleicher Agent)
|
||||||
Task 1+2 → Tasks 3-7 (alle parallel, verschiedene Dateien)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Tasks 3-7 sind **vollstaendig parallel** (verschiedene Widget-Dateien)
|
|
||||||
- Tasks 1+2 muessen zuerst (shared Infrastructure)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
|
|
||||||
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
|
||||||
- [ ] **ProjectHealth** filterbar nach Projektname + Client
|
- [ ] Admin kann Passwort fuer beliebigen User setzen
|
||||||
- [ ] **BudgetForecast** filterbar nach Projektname + Client
|
- [ ] Passwort wird mit Argon2 gehasht (nicht plaintext gespeichert)
|
||||||
- [ ] **Chargeability** filterbar nach Country + Role
|
- [ ] Audit-Log Eintrag wird erstellt (ohne Passwort-Wert)
|
||||||
- [ ] **SkillGap** filterbar nach Skill-Name
|
- [ ] Min 8 Zeichen Validierung im UI und API
|
||||||
- [ ] **TopValue** filterbar nach Chapter
|
- [ ] Passwort-Bestaetigung muss uebereinstimmen
|
||||||
- [ ] Filter-State wird in Widget-Config persistiert (bleibt nach Reload)
|
|
||||||
- [ ] Reset-Button setzt alle Filter zurueck
|
|
||||||
- [ ] Dark-Theme funktioniert fuer alle Filter
|
|
||||||
- [ ] Filter-Optionen werden gecacht (nicht pro Widget neu geladen)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Risiken & offene Fragen
|
## Risiken & offene Fragen
|
||||||
|
|
||||||
### Risiken
|
- **Sicherheit:** Nur ADMIN-Rolle darf Passwoerter setzen (adminProcedure)
|
||||||
- **Widget-Groesse:** Filter-Bar braucht Platz — bei kleinen Widgets koennte es eng werden
|
- **Audit:** Passwort-Wert DARF NICHT im Audit-Log erscheinen
|
||||||
→ Mitigation: Kompakte Inputs (xs), collapsible Filter-Bar mit Funnel-Icon
|
- **UX:** Soll der User benachrichtigt werden? → Vorschlag: Nein, Admin setzt manuell und teilt dem User das Passwort separat mit
|
||||||
- **Performance:** Zusaetzliche tRPC-Queries fuer Client/Country/Role Listen
|
|
||||||
→ Mitigation: Ein shared Hook mit 5-Minuten staleTime, von allen Widgets geteilt
|
|
||||||
|
|
||||||
### Offene Fragen
|
|
||||||
1. **Server-side vs Client-side Filtering?**
|
|
||||||
→ Empfehlung: Client-seitig, da Widget-Daten bereits komplett geladen sind (max 30-50 Rows)
|
|
||||||
2. **Soll der Filter-Bar im Widget-Header oder darunter angezeigt werden?**
|
|
||||||
→ Empfehlung: Direkt unter dem Titel, ueber der Tabelle — kompakt mit kleinen Inputs
|
|
||||||
3. **Sollen Filter-Optionen leer sein wenn keine Daten vorhanden?**
|
|
||||||
→ Ja, leere Dropdowns zeigen "(All)" als Default
|
|
||||||
|
|||||||
Reference in New Issue
Block a user