add project
This commit is contained in:
parent
7de7ef16b9
commit
6fdfbab996
@ -13,7 +13,38 @@
|
|||||||
"Bash(pnpm --filter @ordinarthur-os/pwa typecheck)",
|
"Bash(pnpm --filter @ordinarthur-os/pwa typecheck)",
|
||||||
"Bash(pnpm -r typecheck)",
|
"Bash(pnpm -r typecheck)",
|
||||||
"Bash(pnpm --filter pwa build)",
|
"Bash(pnpm --filter pwa build)",
|
||||||
"Bash(pnpm --filter api build)"
|
"Bash(pnpm --filter api build)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/pwa add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities)",
|
||||||
|
"Bash(pnpm --filter @ordinarthur-os/api build)",
|
||||||
|
"Bash(grep -v \"^$\")",
|
||||||
|
"Bash(node -e \"const m = require\\('./apps/api/dist/main'\\); \")",
|
||||||
|
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get nodes)",
|
||||||
|
"Bash(ping -c 2 -W 2 10.10.10.5)",
|
||||||
|
"Bash(ssh -o ConnectTimeout=5 root@100.78.114.17 \"qm list\")",
|
||||||
|
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"ssh arthur@10.10.10.5 'sudo journalctl -n 30 -u k3s --no-pager' 2>&1\")",
|
||||||
|
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"qm status 103 && qm agent 103 exec -- bash -c 'df -h / && dmesg | tail -20'\")",
|
||||||
|
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"qm status 103 --verbose\")",
|
||||||
|
"Bash(ssh root@100.78.114.17 \"qm terminal 103\")",
|
||||||
|
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get pods -n gitea)",
|
||||||
|
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get svc -n gitea)",
|
||||||
|
"Bash(ssh arthur@100.106.59.13 \"sudo systemctl status traefik --no-pager | head -20\")",
|
||||||
|
"Bash(ssh arthur@100.106.59.13 \"curl -s http://localhost:8080/api/http/services 2>&1 | head -100\")",
|
||||||
|
"Bash(ssh arthur@100.106.59.13 \"cat /etc/traefik/traefik.yml\")",
|
||||||
|
"Read(//Users/arthurbarre/.config/gitea/**)",
|
||||||
|
"Bash(curl -s -X POST https://git.arthurbarre.fr/api/v1/user/repos -H 'Authorization: token __TRACKED_VAR__' -H 'Content-Type: application/json' -d '{\"name\":\"ordinarthur-os\",\"private\":false,\"auto_init\":false}')",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"sudo -u postgres psql -c \\\\\"SELECT usename FROM pg_user WHERE usename = 'ordinarthur';\\\\\"\")",
|
||||||
|
"Bash(ssh arthur@100.114.242.60 \"sudo -u postgres psql -c \\\\\"\\\\\\\\du\\\\\" 2>&1 | grep -E 'ordinarthur|postgres'\")",
|
||||||
|
"Bash(openssl rand:*)",
|
||||||
|
"Bash(ssh arthur@100.114.242.60:*)",
|
||||||
|
"Bash(ansible-playbook playbooks/gateway.yml)",
|
||||||
|
"Bash(docker login:*)",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"nslookup git.arthurbarre.fr && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"cat /etc/resolv.conf && echo '---' && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"curl -v --max-time 10 https://git.arthurbarre.fr/v2/ 2>&1 | head -30\")",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"ping -c 2 1.1.1.1 && curl -s -o /dev/null -w '%{http_code}' https://51.38.62.199 -H 'Host: git.arthurbarre.fr' --insecure\")",
|
||||||
|
"Bash(ssh arthur@100.78.207.119 \"echo '51.38.62.199 git.arthurbarre.fr' | sudo tee -a /etc/hosts && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
|
||||||
|
"Bash(ssh arthur@100.121.251.87 \"echo '51.38.62.199 git.arthurbarre.fr' | sudo tee -a /etc/hosts && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
|
||||||
|
"Bash(curl -s -X POST https://git.arthurbarre.fr/api/v1/user/repos -H 'Authorization: token __TRACKED_VAR__' -H 'Content-Type: application/json' -d '{\"name\":\"rebours\",\"private\":false,\"auto_init\":false}')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +1,45 @@
|
|||||||
# Squelette à aligner sur le skill /deploy d'Arthur.
|
name: Build & Deploy
|
||||||
# - Build images api + pwa
|
|
||||||
# - Push vers Gitea Container Registry
|
|
||||||
# - kubectl set image (ou apply via kustomize plus tard)
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with: { version: 9 }
|
- name: Set image tags
|
||||||
- uses: actions/setup-node@v4
|
run: |
|
||||||
with: { node-version: 20, cache: pnpm }
|
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||||
- run: pnpm install --frozen-lockfile
|
echo "API_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:$SHA" >> $GITHUB_ENV
|
||||||
- run: pnpm -r build
|
echo "PWA_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:$SHA" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login Gitea Container Registry
|
- name: Login Gitea Container Registry
|
||||||
run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
|
|
||||||
|
|
||||||
- name: Build & push images
|
|
||||||
run: |
|
run: |
|
||||||
API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
docker login git.arthurbarre.fr -u ordinarthur --password-stdin
|
||||||
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
|
||||||
docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" .
|
|
||||||
docker push "$API_TAG"
|
|
||||||
docker push "$PWA_TAG"
|
|
||||||
echo "API_TAG=$API_TAG" >> $GITHUB_ENV
|
|
||||||
echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy on k3s
|
- name: Build & push API
|
||||||
|
run: |
|
||||||
|
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
||||||
|
docker push "$API_TAG"
|
||||||
|
|
||||||
|
- name: Build & push PWA
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg VITE_API_BASE_URL=https://api.os.arthurbarre.fr \
|
||||||
|
-f apps/pwa/Dockerfile \
|
||||||
|
-t "$PWA_TAG" .
|
||||||
|
docker push "$PWA_TAG"
|
||||||
|
|
||||||
|
- name: Deploy on K3s
|
||||||
env:
|
env:
|
||||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
|
mkdir -p ~/.kube
|
||||||
|
echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
|
||||||
kubectl -n ordinarthur-os set image deploy/api api=$API_TAG
|
kubectl -n ordinarthur-os set image deploy/api api=$API_TAG
|
||||||
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
|
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
|
||||||
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
|
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
|
||||||
|
|||||||
@ -1,24 +1,42 @@
|
|||||||
# Multi-stage build : build avec pnpm puis runtime minimal
|
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS builder
|
||||||
RUN corepack enable
|
WORKDIR /workspace
|
||||||
WORKDIR /repo
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
|
||||||
COPY apps/api/package.json apps/api/
|
|
||||||
COPY packages/shared/package.json packages/shared/
|
|
||||||
COPY packages/db/package.json packages/db/
|
|
||||||
RUN pnpm install --frozen-lockfile || pnpm install
|
|
||||||
|
|
||||||
FROM deps AS build
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
# package.json seuls d'abord (layer cache pnpm install)
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources complètes
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build dans l'ordre des dépendances
|
||||||
|
RUN pnpm --filter @ordinarthur-os/shared build
|
||||||
|
RUN pnpm --filter @ordinarthur-os/db build
|
||||||
RUN pnpm --filter @ordinarthur-os/api build
|
RUN pnpm --filter @ordinarthur-os/api build
|
||||||
|
|
||||||
|
# Deploy propre : pnpm résout les workspace packages compilés vers /deploy/api
|
||||||
|
RUN pnpm --filter @ordinarthur-os/api deploy --prod /deploy/api
|
||||||
|
|
||||||
|
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS runtime
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN corepack enable
|
|
||||||
COPY --from=build /repo/apps/api/dist ./dist
|
# Deps de prod isolées (inclut @ordinarthur-os/shared+db compilés)
|
||||||
COPY --from=build /repo/apps/api/package.json ./package.json
|
COPY --from=builder /deploy/api/node_modules ./node_modules
|
||||||
COPY --from=build /repo/node_modules ./node_modules
|
COPY --from=builder /deploy/api/package.json ./
|
||||||
COPY --from=build /repo/packages ./packages
|
|
||||||
|
# Code compilé de l'API
|
||||||
|
COPY --from=builder /workspace/apps/api/dist ./dist
|
||||||
|
|
||||||
|
# Migrations SQL (pour le job K3s migrate)
|
||||||
|
COPY --from=builder /workspace/packages/db/migrations ./packages/db/migrations
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { TodosModule } from "./modules/todos/todos.module";
|
|||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
||||||
import { AgendaModule } from "./modules/agenda/agenda.module";
|
import { AgendaModule } from "./modules/agenda/agenda.module";
|
||||||
|
import { ProjectsModule } from "./modules/projects/projects.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,6 +22,7 @@ import { AgendaModule } from "./modules/agenda/agenda.module";
|
|||||||
AiModule,
|
AiModule,
|
||||||
HealthTabModule,
|
HealthTabModule,
|
||||||
AgendaModule,
|
AgendaModule,
|
||||||
|
ProjectsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
@ -3,9 +3,11 @@ import { AiController } from "./ai.controller";
|
|||||||
import { AiService } from "./ai.service";
|
import { AiService } from "./ai.service";
|
||||||
import { MistralClient } from "./mistral.client";
|
import { MistralClient } from "./mistral.client";
|
||||||
import { GroqClient } from "./groq.client";
|
import { GroqClient } from "./groq.client";
|
||||||
|
import { ProjectsModule } from "@/modules/projects/projects.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ProjectsModule],
|
||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [AiService, MistralClient, GroqClient],
|
providers: [AiService, MistralClient, GroqClient],
|
||||||
})
|
})
|
||||||
export class AiModule {}
|
export class AiModule {}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "@ordinarthur-os/shared";
|
} from "@ordinarthur-os/shared";
|
||||||
import { eq, inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { InjectDb, type Db } from "@/db/db.module";
|
import { InjectDb, type Db } from "@/db/db.module";
|
||||||
|
import { ProjectsService } from "@/modules/projects/projects.service";
|
||||||
import { MistralClient } from "./mistral.client";
|
import { MistralClient } from "./mistral.client";
|
||||||
import { GroqClient } from "./groq.client";
|
import { GroqClient } from "./groq.client";
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ export class AiService {
|
|||||||
@InjectDb() private readonly db: Db,
|
@InjectDb() private readonly db: Db,
|
||||||
private readonly mistral: MistralClient,
|
private readonly mistral: MistralClient,
|
||||||
private readonly groq: GroqClient,
|
private readonly groq: GroqClient,
|
||||||
|
private readonly projects: ProjectsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -187,9 +189,28 @@ export class AiService {
|
|||||||
return { todo_id: row.id };
|
return { todo_id: row.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "add_project_idea":
|
case "capture_idea": {
|
||||||
case "add_project_step":
|
const idea = await this.projects.createIdea(action.args.project_id ?? null, {
|
||||||
throw new Error(`Fonction "${action.fn}" pas encore câblée (phase 3)`);
|
content: action.args.content,
|
||||||
|
priority: action.args.priority ?? null,
|
||||||
|
});
|
||||||
|
return { idea_id: idea.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add_project_idea": {
|
||||||
|
const idea = await this.projects.createIdea(action.args.project_id, {
|
||||||
|
content: action.args.content,
|
||||||
|
});
|
||||||
|
return { idea_id: idea.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add_project_step": {
|
||||||
|
const step = await this.projects.createStep(action.args.project_id, {
|
||||||
|
title: action.args.title,
|
||||||
|
status: action.args.status ?? "backlog",
|
||||||
|
});
|
||||||
|
return { step_id: step.id };
|
||||||
|
}
|
||||||
|
|
||||||
case "create_calendar_event": {
|
case "create_calendar_event": {
|
||||||
const [row] = await this.db
|
const [row] = await this.db
|
||||||
|
|||||||
@ -104,6 +104,35 @@ export const MISTRAL_TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "capture_idea",
|
||||||
|
description:
|
||||||
|
"Capture une idée, une piste, une réflexion, un concept à retenir — sans que ce soit une tâche à faire. Utilise cette fonction quand Arthur parle d'une idée, d'un concept, d'une réflexion, d'une chose qui lui vient à l'esprit mais qui n'est pas un TODO concret. Exemple : 'j'ai eu l'idée de...', 'ce serait bien si...', 'idée :', 'je pensais à...'.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description: "L'idée, telle que formulée ou reformulée de façon concise.",
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: "integer",
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 3,
|
||||||
|
description: "0 = basse, 3 = urgente. Défaut 1.",
|
||||||
|
},
|
||||||
|
project_id: {
|
||||||
|
type: "string",
|
||||||
|
format: "uuid",
|
||||||
|
description: "UUID du projet concerné, si l'idée s'y rattache clairement.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface MistralToolCall {
|
interface MistralToolCall {
|
||||||
@ -135,9 +164,19 @@ const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os.
|
|||||||
- Tu ne poses pas de questions : si une info manque, tu proposes un titre court et laisses les champs optionnels vides.
|
- Tu ne poses pas de questions : si une info manque, tu proposes un titre court et laisses les champs optionnels vides.
|
||||||
- Si la demande ne matche aucune fonction (ex : question / discussion), n'appelle aucun outil.
|
- Si la demande ne matche aucune fonction (ex : question / discussion), n'appelle aucun outil.
|
||||||
- N'exécute rien toi-même : tu ne fais que proposer. L'utilisateur confirmera côté UI.
|
- N'exécute rien toi-même : tu ne fais que proposer. L'utilisateur confirmera côté UI.
|
||||||
- Traite les formulations déclaratives comme des demandes d'action. "j'ai rendez-vous", "je dois aller", "j'ai un rdv", "dans mon agenda" → create_calendar_event. "j'ai pris mes médocs", "médicaments pris" → toggle_daily_checkin.
|
|
||||||
- Pour create_calendar_event : si la durée n'est pas précisée, suppose 1h. Si seule l'heure de début est donnée, calcule ends_at = starts_at + 1h.
|
## Distinction TODO vs IDÉE — règle fondamentale
|
||||||
- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 avec l'offset Europe/Paris (+02:00 en été, +01:00 en hiver) en te basant sur l'heure courante fournie. Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`;
|
|
||||||
|
- **create_todo** = quelque chose à FAIRE, une action concrète. Verbes d'action : "je dois", "je vais", "il faut que", "pense à", "relancer", "envoyer", "préparer", "acheter", "appeler".
|
||||||
|
- **capture_idea** = quelque chose à RETENIR, une réflexion, un concept, une piste. Formulations : "idée de", "ce serait bien si", "j'ai pensé à", "et si on faisait", "idée :", "je pensais à", "idée que", "feature", "concept", "réflexion".
|
||||||
|
- En cas de doute : si ce n'est pas une action concrète avec un exécutant (Arthur), c'est une idée → capture_idea.
|
||||||
|
|
||||||
|
## Autres règles
|
||||||
|
|
||||||
|
- Formulations déclaratives d'agenda : "j'ai rendez-vous", "je dois aller", "j'ai un rdv", "dans mon agenda" → create_calendar_event.
|
||||||
|
- "j'ai pris mes médocs", "médicaments pris" → toggle_daily_checkin.
|
||||||
|
- Pour create_calendar_event : si la durée n'est pas précisée, suppose 1h.
|
||||||
|
- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 avec l'offset Europe/Paris (+02:00 en été, +01:00 en hiver). Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MistralClient {
|
export class MistralClient {
|
||||||
|
|||||||
136
apps/api/src/modules/projects/projects.controller.ts
Normal file
136
apps/api/src/modules/projects/projects.controller.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ProjectCreateDto,
|
||||||
|
ProjectIdeaCreateDto,
|
||||||
|
ProjectIdeaPatchDto,
|
||||||
|
ProjectPatchDto,
|
||||||
|
ProjectStepCreateDto,
|
||||||
|
ProjectStepPatchDto,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { ZodPipe } from "@/lib/zod-pipe";
|
||||||
|
import { ProjectsService } from "./projects.service";
|
||||||
|
|
||||||
|
@Controller("projects")
|
||||||
|
export class ProjectsController {
|
||||||
|
constructor(private readonly svc: ProjectsService) {}
|
||||||
|
|
||||||
|
// ── Routes littérales en premier (avant les routes paramétrées /:id) ─────────
|
||||||
|
|
||||||
|
@Get("ideas")
|
||||||
|
listFreeIdeas() {
|
||||||
|
return this.svc.listFreeIdeas();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("ideas/:ideaId")
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteFreeIdea(@Param("ideaId", new ParseUUIDPipe()) ideaId: string) {
|
||||||
|
return this.svc.deleteIdea(ideaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
listProjects() {
|
||||||
|
return this.svc.listProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
createProject(@Body(new ZodPipe(ProjectCreateDto)) dto: ProjectCreateDto) {
|
||||||
|
return this.svc.createProject(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id")
|
||||||
|
getProject(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.svc.getProject(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
patchProject(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body(new ZodPipe(ProjectPatchDto)) dto: ProjectPatchDto,
|
||||||
|
) {
|
||||||
|
return this.svc.patchProject(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteProject(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.svc.deleteProject(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Steps ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get(":id/steps")
|
||||||
|
listSteps(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.svc.listSteps(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/steps")
|
||||||
|
createStep(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body(new ZodPipe(ProjectStepCreateDto)) dto: ProjectStepCreateDto,
|
||||||
|
) {
|
||||||
|
return this.svc.createStep(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/steps/:stepId")
|
||||||
|
patchStep(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Param("stepId", new ParseUUIDPipe()) stepId: string,
|
||||||
|
@Body(new ZodPipe(ProjectStepPatchDto)) dto: ProjectStepPatchDto,
|
||||||
|
) {
|
||||||
|
return this.svc.patchStep(id, stepId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id/steps/:stepId")
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteStep(
|
||||||
|
@Param("id", new ParseUUIDPipe()) _id: string,
|
||||||
|
@Param("stepId", new ParseUUIDPipe()) stepId: string,
|
||||||
|
) {
|
||||||
|
return this.svc.deleteStep(stepId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ideas liées à un projet ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Get(":id/ideas")
|
||||||
|
listIdeas(@Param("id", new ParseUUIDPipe()) id: string) {
|
||||||
|
return this.svc.listIdeas(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/ideas")
|
||||||
|
createIdea(
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body(new ZodPipe(ProjectIdeaCreateDto)) dto: ProjectIdeaCreateDto,
|
||||||
|
) {
|
||||||
|
return this.svc.createIdea(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/ideas/:ideaId")
|
||||||
|
patchIdea(
|
||||||
|
@Param("id", new ParseUUIDPipe()) _id: string,
|
||||||
|
@Param("ideaId", new ParseUUIDPipe()) ideaId: string,
|
||||||
|
@Body(new ZodPipe(ProjectIdeaPatchDto)) dto: ProjectIdeaPatchDto,
|
||||||
|
) {
|
||||||
|
return this.svc.patchIdea(ideaId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id/ideas/:ideaId")
|
||||||
|
@HttpCode(204)
|
||||||
|
deleteIdea(
|
||||||
|
@Param("id", new ParseUUIDPipe()) _id: string,
|
||||||
|
@Param("ideaId", new ParseUUIDPipe()) ideaId: string,
|
||||||
|
) {
|
||||||
|
return this.svc.deleteIdea(ideaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/modules/projects/projects.module.ts
Normal file
10
apps/api/src/modules/projects/projects.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ProjectsController } from "./projects.controller";
|
||||||
|
import { ProjectsService } from "./projects.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProjectsController],
|
||||||
|
providers: [ProjectsService],
|
||||||
|
exports: [ProjectsService],
|
||||||
|
})
|
||||||
|
export class ProjectsModule {}
|
||||||
217
apps/api/src/modules/projects/projects.service.ts
Normal file
217
apps/api/src/modules/projects/projects.service.ts
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { eq, asc, isNull, sql } from "drizzle-orm";
|
||||||
|
import { schema } from "@ordinarthur-os/db";
|
||||||
|
import type {
|
||||||
|
Project,
|
||||||
|
ProjectCreateDto,
|
||||||
|
ProjectIdea,
|
||||||
|
ProjectIdeaCreateDto,
|
||||||
|
ProjectIdeaPatchDto,
|
||||||
|
ProjectPatchDto,
|
||||||
|
ProjectStep,
|
||||||
|
ProjectStepCreateDto,
|
||||||
|
ProjectStepPatchDto,
|
||||||
|
} from "@ordinarthur-os/shared";
|
||||||
|
import { InjectDb, type Db } from "@/db/db.module";
|
||||||
|
|
||||||
|
const { projects, projectSteps, projectIdeas } = schema;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectsService {
|
||||||
|
constructor(@InjectDb() private readonly db: Db) {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listProjects(): Promise<Project[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(asc(projects.createdAt));
|
||||||
|
return rows.map(projectToDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(id: string): Promise<Project> {
|
||||||
|
const [row] = await this.db.select().from(projects).where(eq(projects.id, id));
|
||||||
|
if (!row) throw new NotFoundException(`Project ${id} introuvable`);
|
||||||
|
return projectToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(dto: ProjectCreateDto): Promise<Project> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(projects)
|
||||||
|
.values({
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
status: dto.status ?? "active",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("Insertion project: aucune ligne renvoyée");
|
||||||
|
return projectToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchProject(id: string, dto: ProjectPatchDto): Promise<Project> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description ?? null }),
|
||||||
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, id))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new NotFoundException(`Project ${id} introuvable`);
|
||||||
|
return projectToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(id: string): Promise<void> {
|
||||||
|
const result = await this.db.delete(projects).where(eq(projects.id, id));
|
||||||
|
if (result.count === 0) throw new NotFoundException(`Project ${id} introuvable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Steps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listSteps(projectId: string): Promise<ProjectStep[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(projectSteps)
|
||||||
|
.where(eq(projectSteps.projectId, projectId))
|
||||||
|
.orderBy(asc(projectSteps.position));
|
||||||
|
return rows.map(stepToDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createStep(projectId: string, dto: ProjectStepCreateDto): Promise<ProjectStep> {
|
||||||
|
// Position = max existant dans la colonne + 1000
|
||||||
|
const existing = await this.db
|
||||||
|
.select()
|
||||||
|
.from(projectSteps)
|
||||||
|
.where(eq(projectSteps.projectId, projectId));
|
||||||
|
const colSteps = existing.filter((s) => s.status === (dto.status ?? "backlog"));
|
||||||
|
const maxPos = colSteps.length > 0 ? Math.max(...colSteps.map((s) => s.position)) : -1000;
|
||||||
|
const position = dto.position ?? maxPos + 1000;
|
||||||
|
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(projectSteps)
|
||||||
|
.values({
|
||||||
|
projectId,
|
||||||
|
title: dto.title,
|
||||||
|
status: dto.status ?? "backlog",
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("Insertion step: aucune ligne renvoyée");
|
||||||
|
return stepToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchStep(projectId: string, stepId: string, dto: ProjectStepPatchDto): Promise<ProjectStep> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(projectSteps)
|
||||||
|
.set({
|
||||||
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
|
...(dto.status !== undefined && { status: dto.status }),
|
||||||
|
...(dto.position !== undefined && { position: dto.position }),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(projectSteps.id, stepId))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new NotFoundException(`Step ${stepId} introuvable`);
|
||||||
|
return stepToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteStep(stepId: string): Promise<void> {
|
||||||
|
await this.db.delete(projectSteps).where(eq(projectSteps.id, stepId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ideas
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listFreeIdeas(): Promise<ProjectIdea[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(projectIdeas)
|
||||||
|
.where(isNull(projectIdeas.projectId))
|
||||||
|
.orderBy(asc(projectIdeas.createdAt));
|
||||||
|
return rows.map(ideaToDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listIdeas(projectId: string): Promise<ProjectIdea[]> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(projectIdeas)
|
||||||
|
.where(eq(projectIdeas.projectId, projectId))
|
||||||
|
.orderBy(asc(projectIdeas.createdAt));
|
||||||
|
return rows.map(ideaToDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIdea(projectId: string | null, dto: ProjectIdeaCreateDto): Promise<ProjectIdea> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.insert(projectIdeas)
|
||||||
|
.values({
|
||||||
|
projectId: projectId ?? dto.project_id ?? null,
|
||||||
|
content: dto.content,
|
||||||
|
priority: dto.priority ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error("Insertion idea: aucune ligne renvoyée");
|
||||||
|
return ideaToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchIdea(ideaId: string, dto: ProjectIdeaPatchDto): Promise<ProjectIdea> {
|
||||||
|
const [row] = await this.db
|
||||||
|
.update(projectIdeas)
|
||||||
|
.set({
|
||||||
|
...(dto.content !== undefined && { content: dto.content }),
|
||||||
|
...(dto.priority !== undefined && { priority: dto.priority ?? null }),
|
||||||
|
})
|
||||||
|
.where(eq(projectIdeas.id, ideaId))
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new NotFoundException(`Idea ${ideaId} introuvable`);
|
||||||
|
return ideaToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIdea(ideaId: string): Promise<void> {
|
||||||
|
await this.db.delete(projectIdeas).where(eq(projectIdeas.id, ideaId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function projectToDto(row: typeof projects.$inferSelect): Project {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description ?? null,
|
||||||
|
status: row.status as Project["status"],
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
updated_at: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepToDto(row: typeof projectSteps.$inferSelect): ProjectStep {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
project_id: row.projectId,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status as ProjectStep["status"],
|
||||||
|
position: row.position,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
updated_at: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ideaToDto(row: typeof projectIdeas.$inferSelect): ProjectIdea {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
project_id: row.projectId ?? null,
|
||||||
|
content: row.content,
|
||||||
|
priority: row.priority ?? null,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,15 +1,28 @@
|
|||||||
FROM node:20-alpine AS build
|
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||||
RUN corepack enable
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /repo
|
WORKDIR /workspace
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
|
||||||
COPY apps/pwa/package.json apps/pwa/
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
|
COPY apps/pwa/package.json apps/pwa/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
RUN pnpm install --frozen-lockfile || pnpm install
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# L'URL de l'API est baked-in au build (Vite env var)
|
||||||
|
ARG VITE_API_BASE_URL=https://api.os.arthurbarre.fr
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
|
||||||
RUN pnpm --filter @ordinarthur-os/pwa build
|
RUN pnpm --filter @ordinarthur-os/pwa build
|
||||||
|
|
||||||
|
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
|
||||||
FROM nginx:1.27-alpine AS runtime
|
FROM nginx:1.27-alpine AS runtime
|
||||||
COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html
|
|
||||||
|
COPY --from=builder /workspace/apps/pwa/dist /usr/share/nginx/html
|
||||||
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@ordinarthur-os/shared": "workspace:*",
|
"@ordinarthur-os/shared": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tanstack/react-router": "^1.58.7",
|
"@tanstack/react-router": "^1.58.7",
|
||||||
|
|||||||
117
apps/pwa/src/components/ai/VoiceButton.tsx
Normal file
117
apps/pwa/src/components/ai/VoiceButton.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { AiVoiceResponse } from "@ordinarthur-os/shared";
|
||||||
|
import { apiBinary } from "@/api/client";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { VoiceConfirmModal } from "./VoiceConfirmModal";
|
||||||
|
|
||||||
|
type Phase = "idle" | "recording" | "uploading" | "reviewing" | "error";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** "sm" = bouton compact (icône + label court), "default" = carré large */
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version compacte du magic button, intégrable dans n'importe quelle page.
|
||||||
|
*/
|
||||||
|
export function VoiceButton({ size = "default" }: Props) {
|
||||||
|
const [phase, setPhase] = useState<Phase>("idle");
|
||||||
|
const [result, setResult] = useState<AiVoiceResponse | null>(null);
|
||||||
|
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => streamRef.current?.getTracks().forEach((t) => t.stop()), []);
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (phase === "recording") {
|
||||||
|
recorderRef.current?.stop();
|
||||||
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||||
|
setPhase("uploading");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phase === "uploading" || phase === "reviewing") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
streamRef.current = stream;
|
||||||
|
const mime = pickMime();
|
||||||
|
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
|
||||||
|
chunksRef.current = [];
|
||||||
|
rec.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||||
|
rec.onstop = async () => {
|
||||||
|
const blob = new Blob(chunksRef.current, { type: rec.mimeType });
|
||||||
|
if (!blob.size) { setPhase("idle"); return; }
|
||||||
|
try {
|
||||||
|
const resp = await apiBinary<AiVoiceResponse>("/ai/voice", blob, rec.mimeType || "audio/webm");
|
||||||
|
setResult(resp);
|
||||||
|
setPhase("reviewing");
|
||||||
|
} catch {
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rec.start();
|
||||||
|
recorderRef.current = rec;
|
||||||
|
setPhase("recording");
|
||||||
|
} catch {
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecording = phase === "recording";
|
||||||
|
const isBusy = phase === "uploading";
|
||||||
|
|
||||||
|
if (size === "sm") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggle()}
|
||||||
|
disabled={isBusy}
|
||||||
|
title={isRecording ? "Stop" : "Parler (voice)"}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-label border px-2 py-1 transition-colors",
|
||||||
|
isRecording
|
||||||
|
? "border-accent bg-accent text-bg"
|
||||||
|
: "border-ink text-muted hover:text-ink disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("w-1.5 h-1.5 flex-shrink-0", isRecording ? "bg-bg animate-pulse" : "bg-current")} />
|
||||||
|
{isBusy ? "…" : isRecording ? "Stop" : "Voix"}
|
||||||
|
</button>
|
||||||
|
{phase === "reviewing" && result && (
|
||||||
|
<VoiceConfirmModal response={result} onClose={() => { setResult(null); setPhase("idle"); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default (same as MagicButton but simplified)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggle()}
|
||||||
|
disabled={isBusy}
|
||||||
|
className={cn(
|
||||||
|
"border-2 border-ink px-6 py-4 font-sans text-base font-light w-full transition-colors",
|
||||||
|
isRecording ? "bg-accent text-bg" : "bg-bg text-ink hover:bg-ink hover:text-bg",
|
||||||
|
isBusy && "opacity-50 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isBusy ? "Traitement…" : isRecording ? "Stop" : "🎤 Parler"}
|
||||||
|
</button>
|
||||||
|
{phase === "reviewing" && result && (
|
||||||
|
<VoiceConfirmModal response={result} onClose={() => { setResult(null); setPhase("idle"); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMime(): string | null {
|
||||||
|
for (const t of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) {
|
||||||
|
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) return t;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -43,6 +43,8 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
void queryClient.invalidateQueries({ queryKey: ["todos"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["agenda"] });
|
void queryClient.invalidateQueries({ queryKey: ["agenda"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["health-tab"] });
|
void queryClient.invalidateQueries({ queryKey: ["health-tab"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["ideas", "free"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -256,6 +258,7 @@ function ActionRow({
|
|||||||
function functionLabel(fn: ProposedAction["fn"]): string {
|
function functionLabel(fn: ProposedAction["fn"]): string {
|
||||||
switch (fn) {
|
switch (fn) {
|
||||||
case "create_todo": return "Créer une tâche";
|
case "create_todo": return "Créer une tâche";
|
||||||
|
case "capture_idea": return "Capturer une idée";
|
||||||
case "add_project_idea": return "Idée de projet";
|
case "add_project_idea": return "Idée de projet";
|
||||||
case "add_project_step": return "Étape de projet";
|
case "add_project_step": return "Étape de projet";
|
||||||
case "create_calendar_event": return "Événement agenda";
|
case "create_calendar_event": return "Événement agenda";
|
||||||
@ -279,6 +282,8 @@ function summarize(action: ProposedAction): string {
|
|||||||
return `${action.args.title} · ${formatDatetime(action.args.starts_at)} → ${formatTime(action.args.ends_at)}`;
|
return `${action.args.title} · ${formatDatetime(action.args.starts_at)} → ${formatTime(action.args.ends_at)}`;
|
||||||
case "toggle_daily_checkin":
|
case "toggle_daily_checkin":
|
||||||
return action.args.note ?? "Check-in du jour";
|
return action.args.note ?? "Check-in du jour";
|
||||||
|
case "capture_idea":
|
||||||
|
return action.args.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
apps/pwa/src/components/projects/IdeaList.tsx
Normal file
134
apps/pwa/src/components/projects/IdeaList.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ProjectIdea } from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = ["Bas", "Normal", "Haut", "Urgent"] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
ideas: ProjectIdea[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeaList({ projectId, ideas }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [priority, setPriority] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const addIdea = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api(`/projects/${projectId}/ideas`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content, priority }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "ideas"] });
|
||||||
|
setContent("");
|
||||||
|
setPriority(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
addIdea.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...ideas].sort((a, b) => {
|
||||||
|
const pa = a.priority ?? -1;
|
||||||
|
const pb = b.priority ?? -1;
|
||||||
|
return pb - pa || a.created_at.localeCompare(b.created_at);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Form */}
|
||||||
|
<div className="border border-ink p-3 space-y-2">
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Nouvelle idée</span>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit();
|
||||||
|
}}
|
||||||
|
placeholder="Une idée, une piste, une note…"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-label text-muted">Priorité :</span>
|
||||||
|
{([null, 0, 1, 2, 3] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={String(p)}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriority(p)}
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[9px] uppercase tracking-label px-2 py-0.5 border transition-colors",
|
||||||
|
priority === p
|
||||||
|
? "border-ink bg-ink text-bg"
|
||||||
|
: "border-ink text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p === null ? "—" : PRIORITY_LABELS[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!content.trim() || addIdea.isPending}
|
||||||
|
className="ml-auto font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg px-3 py-1 disabled:opacity-40 hover:bg-accent hover:border-accent"
|
||||||
|
>
|
||||||
|
{addIdea.isPending ? "…" : "Ajouter"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<p className="font-sans text-sm text-muted">Aucune idée pour l'instant.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sorted.map((idea) => (
|
||||||
|
<IdeaRow key={idea.id} idea={idea} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IdeaRow({ idea, projectId }: { idea: ProjectIdea; projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteIdea = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api(`/projects/${projectId}/ideas/${idea.id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "ideas"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="border border-ink px-3 py-2 flex items-start gap-3 group">
|
||||||
|
{idea.priority !== null && (
|
||||||
|
<span className={cn(
|
||||||
|
"flex-shrink-0 font-mono text-[9px] uppercase tracking-label mt-0.5 px-1",
|
||||||
|
idea.priority === 3 ? "bg-accent text-bg" :
|
||||||
|
idea.priority === 2 ? "border border-ink text-ink" :
|
||||||
|
"text-muted",
|
||||||
|
)}>
|
||||||
|
p{idea.priority}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 font-sans text-sm text-ink leading-snug">{idea.content}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteIdea.mutate()}
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
338
apps/pwa/src/components/projects/KanbanBoard.tsx
Normal file
338
apps/pwa/src/components/projects/KanbanBoard.tsx
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
type DragOverEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ProjectStep, StepStatus } from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
const COLUMNS: { id: StepStatus; label: string }[] = [
|
||||||
|
{ id: "backlog", label: "Backlog" },
|
||||||
|
{ id: "todo", label: "À faire" },
|
||||||
|
{ id: "doing", label: "En cours" },
|
||||||
|
{ id: "review", label: "Révision" },
|
||||||
|
{ id: "done", label: "Fait" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
steps: ProjectStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanbanBoard({ projectId, steps }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeStep, setActiveStep] = useState<ProjectStep | null>(null);
|
||||||
|
// Track over-column during drag
|
||||||
|
const [overColumn, setOverColumn] = useState<StepStatus | null>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const patchStep = useMutation({
|
||||||
|
mutationFn: ({ stepId, status, position }: { stepId: string; status: StepStatus; position: number }) =>
|
||||||
|
api(`/projects/${projectId}/steps/${stepId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status, position }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleDragStart(event: DragStartEvent) {
|
||||||
|
const step = steps.find((s) => s.id === event.active.id);
|
||||||
|
if (step) setActiveStep(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragOverEvent) {
|
||||||
|
const overId = event.over?.id as string | undefined;
|
||||||
|
if (!overId) { setOverColumn(null); return; }
|
||||||
|
// Over a column header
|
||||||
|
const col = COLUMNS.find((c) => c.id === overId);
|
||||||
|
if (col) { setOverColumn(col.id); return; }
|
||||||
|
// Over a step — get its column
|
||||||
|
const overStep = steps.find((s) => s.id === overId);
|
||||||
|
if (overStep) setOverColumn(overStep.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
setActiveStep(null);
|
||||||
|
setOverColumn(null);
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const draggedStep = steps.find((s) => s.id === active.id);
|
||||||
|
if (!draggedStep) return;
|
||||||
|
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
// Determine target column
|
||||||
|
let targetStatus: StepStatus;
|
||||||
|
const overCol = COLUMNS.find((c) => c.id === overId);
|
||||||
|
if (overCol) {
|
||||||
|
targetStatus = overCol.id;
|
||||||
|
} else {
|
||||||
|
const overStep = steps.find((s) => s.id === overId);
|
||||||
|
if (!overStep) return;
|
||||||
|
targetStatus = overStep.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ordered steps in target column (excluding dragged)
|
||||||
|
const colSteps = steps
|
||||||
|
.filter((s) => s.status === targetStatus && s.id !== draggedStep.id)
|
||||||
|
.sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
// Find where the dragged step lands within the target column
|
||||||
|
const overStep = steps.find((s) => s.id === overId && s.id !== draggedStep.id);
|
||||||
|
let newPosition: number;
|
||||||
|
|
||||||
|
if (!overStep || overCol) {
|
||||||
|
// Dropped on column header or empty column → end of column
|
||||||
|
const last = colSteps[colSteps.length - 1];
|
||||||
|
newPosition = last ? last.position + 1000 : 0;
|
||||||
|
} else {
|
||||||
|
const overIdx = colSteps.findIndex((s) => s.id === overId);
|
||||||
|
const prev = colSteps[overIdx - 1];
|
||||||
|
const next = colSteps[overIdx];
|
||||||
|
if (prev && next) {
|
||||||
|
newPosition = (prev.position + next.position) / 2;
|
||||||
|
} else if (!prev && next) {
|
||||||
|
newPosition = next.position - 500;
|
||||||
|
} else if (prev && !next) {
|
||||||
|
newPosition = prev.position + 1000;
|
||||||
|
} else {
|
||||||
|
newPosition = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patchStep.mutate({ stepId: draggedStep.id, status: targetStatus, position: newPosition });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsByCol = (status: StepStatus) =>
|
||||||
|
steps.filter((s) => s.status === status).sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 md:mx-0 md:px-0">
|
||||||
|
{COLUMNS.map((col) => {
|
||||||
|
const colSteps = stepsByCol(col.id);
|
||||||
|
const isOver = overColumn === col.id && activeStep?.status !== col.id;
|
||||||
|
return (
|
||||||
|
<KanbanColumn
|
||||||
|
key={col.id}
|
||||||
|
id={col.id}
|
||||||
|
label={col.label}
|
||||||
|
steps={colSteps}
|
||||||
|
projectId={projectId}
|
||||||
|
isOver={isOver}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeStep && <StepCardOverlay step={activeStep} />}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Column ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function KanbanColumn({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
steps,
|
||||||
|
projectId,
|
||||||
|
isOver,
|
||||||
|
}: {
|
||||||
|
id: StepStatus;
|
||||||
|
label: string;
|
||||||
|
steps: ProjectStep[];
|
||||||
|
projectId: string;
|
||||||
|
isOver: boolean;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
|
||||||
|
const { setNodeRef } = useSortable({ id, data: { type: "column" } });
|
||||||
|
|
||||||
|
const createStep = useMutation({
|
||||||
|
mutationFn: (t: string) =>
|
||||||
|
api(`/projects/${projectId}/steps`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title: t, status: id }),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
|
||||||
|
setTitle("");
|
||||||
|
setAdding(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const t = title.trim();
|
||||||
|
if (!t) return;
|
||||||
|
createStep.mutate(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 w-52 md:w-56 flex flex-col border border-ink transition-colors",
|
||||||
|
isOver && "border-accent bg-accent/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-3 py-2 border-b border-ink flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label">{label}</span>
|
||||||
|
<span className="font-mono text-[10px] text-muted">{steps.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex-1 p-2 space-y-2 min-h-[120px]">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepCard key={step.id} step={step} projectId={projectId} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
{/* Add step */}
|
||||||
|
<div className="border-t border-ink p-2">
|
||||||
|
{adding ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submit();
|
||||||
|
if (e.key === "Escape") { setAdding(false); setTitle(""); }
|
||||||
|
}}
|
||||||
|
placeholder="Titre de l'étape…"
|
||||||
|
className="w-full bg-transparent font-sans text-xs text-ink outline-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!title.trim() || createStep.isPending}
|
||||||
|
className="flex-1 font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{createStep.isPending ? "…" : "Ajouter"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAdding(false); setTitle(""); }}
|
||||||
|
className="font-mono text-[9px] uppercase tracking-label border border-ink px-2 py-1 text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="w-full font-mono text-[9px] uppercase tracking-label text-muted hover:text-ink text-left"
|
||||||
|
>
|
||||||
|
+ Étape
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step card (sortable) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepCard({ step, projectId }: { step: ProjectStep; projectId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: step.id, data: { type: "step", step } });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteStep = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api(`/projects/${projectId}/steps/${step.id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"border border-ink bg-bg px-2 py-2 group select-none",
|
||||||
|
isDragging && "opacity-30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-0.5 flex-shrink-0 cursor-grab active:cursor-grabbing text-muted hover:text-ink font-mono text-[10px] leading-none"
|
||||||
|
aria-label="Déplacer"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 font-sans text-xs text-ink leading-snug">{step.title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteStep.mutate()}
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay (ghost while dragging) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function StepCardOverlay({ step }: { step: ProjectStep }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-accent bg-bg px-2 py-2 w-52 md:w-56 shadow-lg rotate-1">
|
||||||
|
<span className="font-sans text-xs text-ink leading-snug">{step.title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/", label: "Home", symbol: "◎" },
|
{ to: "/", label: "Home", symbol: "◎" },
|
||||||
{ to: "/agenda", label: "Agenda", symbol: "◫" },
|
{ to: "/agenda", label: "Agenda", symbol: "◫" },
|
||||||
{ to: "/todos", label: "Todos", symbol: "□" },
|
{ to: "/todos", label: "Todos", symbol: "□" },
|
||||||
{ to: "/jobs", label: "Jobs", symbol: "⚡" },
|
{ to: "/projects", label: "Projets", symbol: "◈" },
|
||||||
|
{ to: "/jobs", label: "Jobs", symbol: "⚡" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
|
|||||||
@ -10,16 +10,24 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TodosRouteImport } from './routes/todos'
|
import { Route as TodosRouteImport } from './routes/todos'
|
||||||
|
import { Route as ProjectsRouteImport } from './routes/projects'
|
||||||
import { Route as JobsRouteImport } from './routes/jobs'
|
import { Route as JobsRouteImport } from './routes/jobs'
|
||||||
import { Route as AgendaRouteImport } from './routes/agenda'
|
import { Route as AgendaRouteImport } from './routes/agenda'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ProjectsIndexRouteImport } from './routes/projects.index'
|
||||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||||
|
import { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId'
|
||||||
|
|
||||||
const TodosRoute = TodosRouteImport.update({
|
const TodosRoute = TodosRouteImport.update({
|
||||||
id: '/todos',
|
id: '/todos',
|
||||||
path: '/todos',
|
path: '/todos',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectsRoute = ProjectsRouteImport.update({
|
||||||
|
id: '/projects',
|
||||||
|
path: '/projects',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const JobsRoute = JobsRouteImport.update({
|
const JobsRoute = JobsRouteImport.update({
|
||||||
id: '/jobs',
|
id: '/jobs',
|
||||||
path: '/jobs',
|
path: '/jobs',
|
||||||
@ -35,46 +43,89 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectsIndexRoute = ProjectsIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => ProjectsRoute,
|
||||||
|
} as any)
|
||||||
const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
||||||
id: '/settings/jobs',
|
id: '/settings/jobs',
|
||||||
path: '/settings/jobs',
|
path: '/settings/jobs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({
|
||||||
|
id: '/$projectId',
|
||||||
|
path: '/$projectId',
|
||||||
|
getParentRoute: () => ProjectsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/agenda': typeof AgendaRoute
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/projects': typeof ProjectsRouteWithChildren
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects/': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/agenda': typeof AgendaRoute
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/agenda': typeof AgendaRoute
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/projects': typeof ProjectsRouteWithChildren
|
||||||
'/todos': typeof TodosRoute
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects/': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/projects'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
to:
|
||||||
id: '__root__' | '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/projects'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AgendaRoute: typeof AgendaRoute
|
AgendaRoute: typeof AgendaRoute
|
||||||
JobsRoute: typeof JobsRoute
|
JobsRoute: typeof JobsRoute
|
||||||
|
ProjectsRoute: typeof ProjectsRouteWithChildren
|
||||||
TodosRoute: typeof TodosRoute
|
TodosRoute: typeof TodosRoute
|
||||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
@ -88,6 +139,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof TodosRouteImport
|
preLoaderRoute: typeof TodosRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/projects': {
|
||||||
|
id: '/projects'
|
||||||
|
path: '/projects'
|
||||||
|
fullPath: '/projects'
|
||||||
|
preLoaderRoute: typeof ProjectsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/jobs': {
|
'/jobs': {
|
||||||
id: '/jobs'
|
id: '/jobs'
|
||||||
path: '/jobs'
|
path: '/jobs'
|
||||||
@ -109,6 +167,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/projects/': {
|
||||||
|
id: '/projects/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/projects/'
|
||||||
|
preLoaderRoute: typeof ProjectsIndexRouteImport
|
||||||
|
parentRoute: typeof ProjectsRoute
|
||||||
|
}
|
||||||
'/settings/jobs': {
|
'/settings/jobs': {
|
||||||
id: '/settings/jobs'
|
id: '/settings/jobs'
|
||||||
path: '/settings/jobs'
|
path: '/settings/jobs'
|
||||||
@ -116,13 +181,35 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SettingsJobsRouteImport
|
preLoaderRoute: typeof SettingsJobsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/projects/$projectId': {
|
||||||
|
id: '/projects/$projectId'
|
||||||
|
path: '/$projectId'
|
||||||
|
fullPath: '/projects/$projectId'
|
||||||
|
preLoaderRoute: typeof ProjectsProjectIdRouteImport
|
||||||
|
parentRoute: typeof ProjectsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectsRouteChildren {
|
||||||
|
ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute
|
||||||
|
ProjectsIndexRoute: typeof ProjectsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectsRouteChildren: ProjectsRouteChildren = {
|
||||||
|
ProjectsProjectIdRoute: ProjectsProjectIdRoute,
|
||||||
|
ProjectsIndexRoute: ProjectsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectsRouteWithChildren = ProjectsRoute._addFileChildren(
|
||||||
|
ProjectsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AgendaRoute: AgendaRoute,
|
AgendaRoute: AgendaRoute,
|
||||||
JobsRoute: JobsRoute,
|
JobsRoute: JobsRoute,
|
||||||
|
ProjectsRoute: ProjectsRouteWithChildren,
|
||||||
TodosRoute: TodosRoute,
|
TodosRoute: TodosRoute,
|
||||||
SettingsJobsRoute: SettingsJobsRoute,
|
SettingsJobsRoute: SettingsJobsRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ function RootLayout() {
|
|||||||
<NavLink to="/">Dashboard</NavLink>
|
<NavLink to="/">Dashboard</NavLink>
|
||||||
<NavLink to="/agenda">Agenda</NavLink>
|
<NavLink to="/agenda">Agenda</NavLink>
|
||||||
<NavLink to="/todos">Todos</NavLink>
|
<NavLink to="/todos">Todos</NavLink>
|
||||||
|
<NavLink to="/projects">Projets</NavLink>
|
||||||
<NavLink to="/jobs">Jobs</NavLink>
|
<NavLink to="/jobs">Jobs</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
183
apps/pwa/src/routes/projects.$projectId.tsx
Normal file
183
apps/pwa/src/routes/projects.$projectId.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Project, ProjectStep, ProjectIdea } from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { Label } from "@/design";
|
||||||
|
import { KanbanBoard } from "@/components/projects/KanbanBoard";
|
||||||
|
import { IdeaList } from "@/components/projects/IdeaList";
|
||||||
|
import { VoiceButton } from "@/components/ai/VoiceButton";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/projects/$projectId")({
|
||||||
|
component: ProjectDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type Tab = "kanban" | "ideas";
|
||||||
|
|
||||||
|
function ProjectDetailPage() {
|
||||||
|
const { projectId } = Route.useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tab, setTab] = useState<Tab>("kanban");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
|
||||||
|
const { data: project, isLoading: loadingProject } = useQuery<Project>({
|
||||||
|
queryKey: ["projects", projectId],
|
||||||
|
queryFn: () => api(`/projects/${projectId}`),
|
||||||
|
// The list endpoint returns all projects, so we derive from cache when possible
|
||||||
|
placeholderData: () => {
|
||||||
|
const all = queryClient.getQueryData<Project[]>(["projects"]);
|
||||||
|
return all?.find((p) => p.id === projectId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: steps = [] } = useQuery<ProjectStep[]>({
|
||||||
|
queryKey: ["projects", projectId, "steps"],
|
||||||
|
queryFn: () => api(`/projects/${projectId}/steps`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: ideas = [] } = useQuery<ProjectIdea[]>({
|
||||||
|
queryKey: ["projects", projectId, "ideas"],
|
||||||
|
queryFn: () => api(`/projects/${projectId}/ideas`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchProject = useMutation({
|
||||||
|
mutationFn: (dto: { name?: string; description?: string | null }) =>
|
||||||
|
api(`/projects/${projectId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setEditName(project?.name ?? "");
|
||||||
|
setEditDesc(project?.description ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
patchProject.mutate({
|
||||||
|
name: editName.trim() || undefined,
|
||||||
|
description: editDesc.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingProject && !project) {
|
||||||
|
return <p className="font-sans text-sm text-muted">Chargement…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="font-sans text-sm text-muted">Projet introuvable.</p>
|
||||||
|
<Link to="/projects" className="font-mono text-[10px] uppercase tracking-label border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg">
|
||||||
|
← Retour
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
to="/projects"
|
||||||
|
className="font-mono text-[9px] uppercase tracking-label text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
← Projets
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-2 border border-ink p-3">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && saveEdit()}
|
||||||
|
className="w-full bg-transparent font-sans text-lg font-medium text-ink outline-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
placeholder="Description (optionnel)"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={patchProject.isPending}
|
||||||
|
className="font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg px-3 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="font-mono text-[9px] uppercase tracking-label border border-ink px-3 py-1 text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-sans font-light text-2xl md:text-4xl text-ink tracking-tightest">{project.name}</h1>
|
||||||
|
{project.description && (
|
||||||
|
<p className="font-sans text-sm text-muted mt-1">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEdit}
|
||||||
|
className="flex-shrink-0 font-mono text-[9px] uppercase tracking-label border border-ink px-2 py-1 text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
Éditer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-0 border-b border-ink">
|
||||||
|
{(["kanban", "ideas"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={[
|
||||||
|
"font-mono text-[10px] uppercase tracking-label px-4 py-2 border-b-2 transition-colors",
|
||||||
|
tab === t
|
||||||
|
? "border-ink text-ink"
|
||||||
|
: "border-transparent text-muted hover:text-ink",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{t === "kanban" ? `Kanban (${steps.length})` : `Idées (${ideas.length})`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Voice button aligné à droite */}
|
||||||
|
<div className="ml-auto pb-1 flex items-center">
|
||||||
|
<VoiceButton size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contenu */}
|
||||||
|
{tab === "kanban" ? (
|
||||||
|
<KanbanBoard projectId={projectId} steps={steps} />
|
||||||
|
) : (
|
||||||
|
<IdeaList projectId={projectId} ideas={ideas} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
apps/pwa/src/routes/projects.index.tsx
Normal file
201
apps/pwa/src/routes/projects.index.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Project, ProjectCreateDto, ProjectIdea } from "@ordinarthur-os/shared";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { Label } from "@/design";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/projects/")({
|
||||||
|
component: ProjectsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProjectsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: "", description: "" });
|
||||||
|
|
||||||
|
const { data: projects = [], isLoading } = useQuery<Project[]>({
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: () => api("/projects"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProject = useMutation({
|
||||||
|
mutationFn: (dto: ProjectCreateDto) =>
|
||||||
|
api("/projects", { method: "POST", body: JSON.stringify(dto) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
setCreating(false);
|
||||||
|
setForm({ name: "", description: "" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveProject = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api(`/projects/${id}`, { method: "PATCH", body: JSON.stringify({ status: "archived" }) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = projects.filter((p) => p.status === "active");
|
||||||
|
const archived = projects.filter((p) => p.status === "archived");
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
createProject.mutate({ name, description: form.description.trim() || null, status: "active" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="font-sans font-light text-3xl md:text-5xl text-ink tracking-tightest">Projets</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreating((v) => !v)}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-label border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg transition-colors"
|
||||||
|
>
|
||||||
|
{creating ? "Annuler" : "+ Nouveau"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="border border-ink p-4 space-y-3">
|
||||||
|
<Label>NOUVEAU PROJET</Label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||||
|
placeholder="Nom du projet"
|
||||||
|
className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="Description (optionnel)"
|
||||||
|
rows={2}
|
||||||
|
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!form.name.trim() || createProject.isPending}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-label border border-ink bg-ink text-bg px-4 py-2 hover:bg-accent hover:border-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{createProject.isPending ? "Création…" : "Créer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="font-sans text-sm text-muted">Chargement…</p>
|
||||||
|
) : active.length === 0 && !creating ? (
|
||||||
|
<p className="font-sans text-sm text-muted">Aucun projet actif. Crée-en un !</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-px">
|
||||||
|
{active.map((p) => (
|
||||||
|
<ProjectRow key={p.id} project={p} onArchive={() => archiveProject.mutate(p.id)} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted">ARCHIVÉS ({archived.length})</Label>
|
||||||
|
<ul className="space-y-px opacity-50">
|
||||||
|
{archived.map((p) => (
|
||||||
|
<ProjectRow key={p.id} project={p} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FreeIdeasSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_LABELS = ["Bas", "Normal", "Haut", "Urgent"] as const;
|
||||||
|
|
||||||
|
function FreeIdeasSection() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: ideas = [] } = useQuery<ProjectIdea[]>({
|
||||||
|
queryKey: ["ideas", "free"],
|
||||||
|
queryFn: () => api("/projects/ideas"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteIdea = useMutation({
|
||||||
|
mutationFn: (id: string) => api(`/projects/ideas/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["ideas", "free"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ideas.length === 0) return null;
|
||||||
|
|
||||||
|
const sorted = [...ideas].sort((a, b) => (b.priority ?? -1) - (a.priority ?? -1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-ink pt-6">
|
||||||
|
<Label>IDÉES LIBRES ({ideas.length})</Label>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{sorted.map((idea) => (
|
||||||
|
<li key={idea.id} className="border border-ink px-3 py-2 flex items-start gap-3 group">
|
||||||
|
{idea.priority !== null && (
|
||||||
|
<span className={cn(
|
||||||
|
"flex-shrink-0 font-mono text-[9px] uppercase tracking-label mt-0.5 px-1",
|
||||||
|
idea.priority === 3 ? "bg-accent text-bg" :
|
||||||
|
idea.priority === 2 ? "border border-ink text-ink" :
|
||||||
|
"text-muted",
|
||||||
|
)}>
|
||||||
|
{PRIORITY_LABELS[idea.priority]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 font-sans text-sm text-ink leading-snug">{idea.content}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteIdea.mutate(idea.id)}
|
||||||
|
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectRow({ project, onArchive }: { project: Project; onArchive?: () => void }) {
|
||||||
|
return (
|
||||||
|
<li className="border border-ink group flex items-center">
|
||||||
|
<Link
|
||||||
|
to="/projects/$projectId"
|
||||||
|
params={{ projectId: project.id }}
|
||||||
|
className="flex-1 px-4 py-3 hover:bg-ink hover:text-bg transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-label text-accent">◈</span>
|
||||||
|
<span className="font-sans text-sm font-medium text-ink group-hover:text-bg">{project.name}</span>
|
||||||
|
{project.description && (
|
||||||
|
<span className="font-sans text-xs text-muted group-hover:text-bg/70 truncate hidden sm:block">
|
||||||
|
{project.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{onArchive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onArchive}
|
||||||
|
className="px-3 py-3 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-all"
|
||||||
|
aria-label="Archiver"
|
||||||
|
>
|
||||||
|
Archiver
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
apps/pwa/src/routes/projects.tsx
Normal file
5
apps/pwa/src/routes/projects.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/projects")({
|
||||||
|
component: () => <Outlet />,
|
||||||
|
});
|
||||||
@ -9,9 +9,11 @@ spec:
|
|||||||
template:
|
template:
|
||||||
metadata: { labels: { app: api } }
|
metadata: { labels: { app: api } }
|
||||||
spec:
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
|
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports: [{ containerPort: 3000 }]
|
ports: [{ containerPort: 3000 }]
|
||||||
envFrom:
|
envFrom:
|
||||||
@ -19,6 +21,7 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- { name: NODE_ENV, value: production }
|
- { name: NODE_ENV, value: production }
|
||||||
- { name: PORT, value: "3000" }
|
- { name: PORT, value: "3000" }
|
||||||
|
- { name: PWA_URL, value: "https://os.arthurbarre.fr" }
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: { path: /health, port: 3000 }
|
httpGet: { path: /health, port: 3000 }
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
@ -37,5 +40,9 @@ metadata:
|
|||||||
name: api
|
name: api
|
||||||
namespace: ordinarthur-os
|
namespace: ordinarthur-os
|
||||||
spec:
|
spec:
|
||||||
|
type: NodePort
|
||||||
selector: { app: api }
|
selector: { app: api }
|
||||||
ports: [{ port: 3000, targetPort: 3000 }]
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
nodePort: 30100
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
# À aligner sur la conf Traefik / cert-manager d'Arthur (cf. /Users/arthurbarre/dev/perso/proxmox).
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: ordinarthur-os
|
|
||||||
namespace: ordinarthur-os
|
|
||||||
annotations:
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
|
||||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
|
|
||||||
secretName: ordinarthur-os-tls
|
|
||||||
rules:
|
|
||||||
- host: os.arthurbarre.fr
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend: { service: { name: pwa, port: { number: 80 } } }
|
|
||||||
- host: api.os.arthurbarre.fr
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend: { service: { name: api, port: { number: 3000 } } }
|
|
||||||
@ -1,13 +1,10 @@
|
|||||||
# Job one-shot qui applique les migrations Drizzle.
|
# Relancer après chaque déploiement qui contient une migration :
|
||||||
# À rejouer manuellement après chaque déploiement qui contient une migration :
|
# kubectl -n ordinarthur-os delete job migrate --ignore-not-found
|
||||||
# kubectl -n ordinarthur-os delete job ordinarthur-os-migrate --ignore-not-found
|
# kubectl -n ordinarthur-os apply -f deploy/k8s/migrate.job.yaml
|
||||||
# kubectl -n ordinarthur-os apply -f migrate.job.yaml
|
|
||||||
#
|
|
||||||
# (Peut aussi être branché dans le pipeline Gitea pour être auto-déclenché.)
|
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
metadata:
|
metadata:
|
||||||
name: ordinarthur-os-migrate
|
name: migrate
|
||||||
namespace: ordinarthur-os
|
namespace: ordinarthur-os
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
@ -15,13 +12,26 @@ spec:
|
|||||||
template:
|
template:
|
||||||
spec:
|
spec:
|
||||||
restartPolicy: OnFailure
|
restartPolicy: OnFailure
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
containers:
|
containers:
|
||||||
- name: migrate
|
- name: migrate
|
||||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-migrate:latest
|
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef: { name: ordinarthur-os-secrets }
|
- secretRef: { name: ordinarthur-os-secrets }
|
||||||
command: ["node", "dist/migrate.js"]
|
# Drizzle migrator via node inline — les migrations SQL sont dans /app/packages/db/migrations
|
||||||
|
command:
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- |
|
||||||
|
const { createDb } = require('@ordinarthur-os/db');
|
||||||
|
const { migrate } = require('drizzle-orm/postgres-js/migrator');
|
||||||
|
const path = require('path');
|
||||||
|
const { db, close } = createDb(process.env.DATABASE_URL);
|
||||||
|
migrate(db, { migrationsFolder: path.join('/app', 'packages/db/migrations') })
|
||||||
|
.then(() => { console.log('[migrate] done'); return close(); })
|
||||||
|
.catch(e => { console.error(e); process.exit(1); });
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 50m, memory: 128Mi }
|
requests: { cpu: 50m, memory: 128Mi }
|
||||||
limits: { cpu: 300m, memory: 256Mi }
|
limits: { cpu: 300m, memory: 256Mi }
|
||||||
|
|||||||
@ -9,14 +9,17 @@ spec:
|
|||||||
template:
|
template:
|
||||||
metadata: { labels: { app: pwa } }
|
metadata: { labels: { app: pwa } }
|
||||||
spec:
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
containers:
|
containers:
|
||||||
- name: pwa
|
- name: pwa
|
||||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
|
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports: [{ containerPort: 80 }]
|
ports: [{ containerPort: 80 }]
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: { path: /, port: 80 }
|
httpGet: { path: /, port: 80 }
|
||||||
initialDelaySeconds: 3
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 20m, memory: 32Mi }
|
requests: { cpu: 20m, memory: 32Mi }
|
||||||
limits: { cpu: 100m, memory: 128Mi }
|
limits: { cpu: 100m, memory: 128Mi }
|
||||||
@ -27,5 +30,9 @@ metadata:
|
|||||||
name: pwa
|
name: pwa
|
||||||
namespace: ordinarthur-os
|
namespace: ordinarthur-os
|
||||||
spec:
|
spec:
|
||||||
|
type: NodePort
|
||||||
selector: { app: pwa }
|
selector: { app: pwa }
|
||||||
ports: [{ port: 80, targetPort: 80 }]
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 80
|
||||||
|
nodePort: 30099
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# NE PAS COMMITER LES VRAIES VALEURS.
|
# NE PAS COMMITER LES VRAIES VALEURS.
|
||||||
# Deux Secrets sont attendus côté cluster — dupliquer, remplir, puis :
|
# Dupliquer, remplir, puis :
|
||||||
# kubectl -n ordinarthur-os apply -f secrets.yaml
|
# kubectl -n ordinarthur-os apply -f secrets.yaml
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@ -10,9 +10,7 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
API_BEARER_TOKEN: ""
|
API_BEARER_TOKEN: ""
|
||||||
# Postgres standalone dans le cluster (cf. postgres.yaml).
|
DATABASE_URL: "postgres://ordinarthur:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os"
|
||||||
# Format : postgres://<user>:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/<db>
|
|
||||||
DATABASE_URL: ""
|
|
||||||
MISTRAL_API_KEY: ""
|
MISTRAL_API_KEY: ""
|
||||||
MISTRAL_MODEL: "mistral-small-latest"
|
MISTRAL_MODEL: "mistral-small-latest"
|
||||||
GROQ_API_KEY: ""
|
GROQ_API_KEY: ""
|
||||||
@ -24,8 +22,6 @@ stringData:
|
|||||||
TELEGRAM_BOT_TOKEN: ""
|
TELEGRAM_BOT_TOKEN: ""
|
||||||
TELEGRAM_WEBHOOK_SECRET: ""
|
TELEGRAM_WEBHOOK_SECRET: ""
|
||||||
---
|
---
|
||||||
# Credentials consommés par le StatefulSet postgres.
|
|
||||||
# Les mêmes valeurs doivent composer DATABASE_URL ci-dessus.
|
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@ -36,18 +32,3 @@ stringData:
|
|||||||
POSTGRES_USER: "ordinarthur"
|
POSTGRES_USER: "ordinarthur"
|
||||||
POSTGRES_PASSWORD: ""
|
POSTGRES_PASSWORD: ""
|
||||||
POSTGRES_DB: "ordinarthur_os"
|
POSTGRES_DB: "ordinarthur_os"
|
||||||
---
|
|
||||||
# Credentials du CronJob de backup (bucket S3-compatible à choisir avec Arthur).
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: ordinarthur-os-backup-secrets
|
|
||||||
namespace: ordinarthur-os
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
# Même valeur que DATABASE_URL (utilisable par pg_dump).
|
|
||||||
PGURL: ""
|
|
||||||
# rclone remote name + bucket, ex. "b2:ordinarthur-os-backups"
|
|
||||||
RCLONE_REMOTE: ""
|
|
||||||
# Contenu d'un rclone.conf — monté ensuite côté cronjob si besoin.
|
|
||||||
RCLONE_CONFIG: ""
|
|
||||||
|
|||||||
31
packages/db/migrations/0006_projects.sql
Normal file
31
packages/db/migrations/0006_projects.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- Phase 3 — Projects, Steps, Ideas
|
||||||
|
|
||||||
|
CREATE TABLE ordinarthur_os.projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ordinarthur_os.project_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES ordinarthur_os.projects(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'backlog',
|
||||||
|
position REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ordinarthur_os.project_ideas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES ordinarthur_os.projects(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
priority SMALLINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX project_steps_project_id_idx ON ordinarthur_os.project_steps(project_id);
|
||||||
|
CREATE INDEX project_ideas_project_id_idx ON ordinarthur_os.project_ideas(project_id);
|
||||||
3
packages/db/migrations/0007_ideas_nullable_project.sql
Normal file
3
packages/db/migrations/0007_ideas_nullable_project.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-- Phase 3 — capture_idea : project_id devient nullable (idées libres sans projet)
|
||||||
|
ALTER TABLE ordinarthur_os.project_ideas
|
||||||
|
ALTER COLUMN project_id DROP NOT NULL;
|
||||||
@ -43,6 +43,20 @@
|
|||||||
"when": 1745193600000,
|
"when": 1745193600000,
|
||||||
"tag": "0005_agenda",
|
"tag": "0005_agenda",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745280000000,
|
||||||
|
"tag": "0006_projects",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745366400000,
|
||||||
|
"tag": "0007_ideas_nullable_project",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export * from "./todos";
|
|||||||
export * from "./ai_actions";
|
export * from "./ai_actions";
|
||||||
export * from "./daily_checkins";
|
export * from "./daily_checkins";
|
||||||
export * from "./calendar_events";
|
export * from "./calendar_events";
|
||||||
|
export * from "./projects";
|
||||||
|
|||||||
47
packages/db/src/schema/projects.ts
Normal file
47
packages/db/src/schema/projects.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { index, real, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { appSchema } from "./_schema";
|
||||||
|
|
||||||
|
export const projects = appSchema.table("projects", {
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
status: text("status").notNull().default("active"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectSteps = appSchema.table(
|
||||||
|
"project_steps",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
status: text("status").notNull().default("backlog"),
|
||||||
|
position: real("position").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
projectIdIdx: index("project_steps_project_id_idx").on(t.projectId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const projectIdeas = appSchema.table(
|
||||||
|
"project_ideas",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
priority: smallint("priority"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
projectIdIdx: index("project_ideas_project_id_idx").on(t.projectId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ProjectRow = typeof projects.$inferSelect;
|
||||||
|
export type ProjectInsert = typeof projects.$inferInsert;
|
||||||
|
export type ProjectStepRow = typeof projectSteps.$inferSelect;
|
||||||
|
export type ProjectIdeaRow = typeof projectIdeas.$inferSelect;
|
||||||
@ -2,13 +2,16 @@
|
|||||||
"name": "@ordinarthur-os/shared",
|
"name": "@ordinarthur-os/shared",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"main": "./dist/index.js",
|
||||||
"main": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -210,6 +210,80 @@ export const TodoAiEnrichApplyRequest = z.object({
|
|||||||
});
|
});
|
||||||
export type TodoAiEnrichApplyRequest = z.infer<typeof TodoAiEnrichApplyRequest>;
|
export type TodoAiEnrichApplyRequest = z.infer<typeof TodoAiEnrichApplyRequest>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 3 — Projects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ProjectStatus = z.enum(["active", "archived"]);
|
||||||
|
export type ProjectStatus = z.infer<typeof ProjectStatus>;
|
||||||
|
|
||||||
|
export const StepStatus = z.enum(["backlog", "todo", "doing", "review", "done"]);
|
||||||
|
export type StepStatus = z.infer<typeof StepStatus>;
|
||||||
|
|
||||||
|
export const Project = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
status: ProjectStatus,
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
});
|
||||||
|
export type Project = z.infer<typeof Project>;
|
||||||
|
|
||||||
|
export const ProjectCreateDto = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: ProjectStatus.default("active"),
|
||||||
|
});
|
||||||
|
export type ProjectCreateDto = z.infer<typeof ProjectCreateDto>;
|
||||||
|
|
||||||
|
export const ProjectPatchDto = ProjectCreateDto.partial();
|
||||||
|
export type ProjectPatchDto = z.infer<typeof ProjectPatchDto>;
|
||||||
|
|
||||||
|
export const ProjectStep = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
project_id: z.string().uuid(),
|
||||||
|
title: z.string(),
|
||||||
|
status: StepStatus,
|
||||||
|
position: z.number(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
});
|
||||||
|
export type ProjectStep = z.infer<typeof ProjectStep>;
|
||||||
|
|
||||||
|
export const ProjectStepCreateDto = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
status: StepStatus.default("backlog"),
|
||||||
|
position: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type ProjectStepCreateDto = z.infer<typeof ProjectStepCreateDto>;
|
||||||
|
|
||||||
|
export const ProjectStepPatchDto = z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
status: StepStatus.optional(),
|
||||||
|
position: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type ProjectStepPatchDto = z.infer<typeof ProjectStepPatchDto>;
|
||||||
|
|
||||||
|
export const ProjectIdea = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
project_id: z.string().uuid().nullable(),
|
||||||
|
content: z.string(),
|
||||||
|
priority: z.number().int().min(0).max(3).nullable(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
export type ProjectIdea = z.infer<typeof ProjectIdea>;
|
||||||
|
|
||||||
|
export const ProjectIdeaCreateDto = z.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
priority: z.number().int().min(0).max(3).optional().nullable(),
|
||||||
|
project_id: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
export type ProjectIdeaCreateDto = z.infer<typeof ProjectIdeaCreateDto>;
|
||||||
|
|
||||||
|
export const ProjectIdeaPatchDto = ProjectIdeaCreateDto.partial();
|
||||||
|
export type ProjectIdeaPatchDto = z.infer<typeof ProjectIdeaPatchDto>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -257,6 +331,14 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
fn: z.literal("capture_idea"),
|
||||||
|
args: z.object({
|
||||||
|
content: z.string(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
project_id: z.string().uuid().optional(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
export type ProposedAction = z.infer<typeof ProposedAction>;
|
export type ProposedAction = z.infer<typeof ProposedAction>;
|
||||||
|
|
||||||
|
|||||||
14
packages/shared/tsconfig.build.json
Normal file
14
packages/shared/tsconfig.build.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@ -72,6 +72,15 @@ importers:
|
|||||||
|
|
||||||
apps/pwa:
|
apps/pwa:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@18.3.1)
|
||||||
'@ordinarthur-os/shared':
|
'@ordinarthur-os/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
@ -717,6 +726,28 @@ packages:
|
|||||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2':
|
'@drizzle-team/brocli@0.10.2':
|
||||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||||
|
|
||||||
@ -4804,6 +4835,31 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@jridgewell/trace-mapping': 0.3.9
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||||
|
react: 18.3.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@drizzle-team/brocli@0.10.2': {}
|
'@drizzle-team/brocli@0.10.2': {}
|
||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user