feat: add Kanban board view with customizable stages
- New "Kanban" view style alongside Simple, Calendar, and List - Drag tasks between columns to change their stage - Customizable stages with colors in Settings > View Style - Stage colors appear as left border indicators on tasks in weekly view - Default stages: Backlog, To Do, In Progress, Review, Done - Stages persist in database (User.kanbanStages as JSON) - Task stage persists in database (Task.kanbanStage) - Full i18n support (EN, DE, FR, ES, IT) - Unassigned tasks shown in separate column v1.28.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7b02e648ab
commit
af2de09f6b
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.27.3",
|
"version": "1.28.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- Add kanbanStage to Task
|
||||||
|
ALTER TABLE "Task" ADD COLUMN IF NOT EXISTS "kanbanStage" TEXT;
|
||||||
|
|
||||||
|
-- Add kanbanStages (JSON string) to User for stage definitions
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "kanbanStages" TEXT;
|
||||||
@ -99,6 +99,7 @@ model User {
|
|||||||
startDayOffset Int @default(-1)
|
startDayOffset Int @default(-1)
|
||||||
quoteSourceUrls String[] @default([])
|
quoteSourceUrls String[] @default([])
|
||||||
quoteLanguages String[] @default(["en", "de"])
|
quoteLanguages String[] @default(["en", "de"])
|
||||||
|
kanbanStages String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
cachedCalendarEvents CachedCalendarEvent[]
|
cachedCalendarEvents CachedCalendarEvent[]
|
||||||
calendarConnections CalendarConnection[]
|
calendarConnections CalendarConnection[]
|
||||||
@ -175,6 +176,7 @@ model Task {
|
|||||||
parentTaskId String?
|
parentTaskId String?
|
||||||
somedaySlotIndex Int?
|
somedaySlotIndex Int?
|
||||||
projectId String?
|
projectId String?
|
||||||
|
kanbanStage String?
|
||||||
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
parent Task? @relation("SubTasks", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
||||||
subTasks Task[] @relation("SubTasks")
|
subTasks Task[] @relation("SubTasks")
|
||||||
project Project? @relation(fields: [projectId], references: [id])
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
|
|||||||
@ -220,7 +220,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId } = body;
|
const { title, description, dayOfWeek, order, markdownContent, somedayListId, somedaySlotIndex, startTime, scheduledDate, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, parentTaskId, projectId, kanbanStage } = body;
|
||||||
let { isRolling } = body;
|
let { isRolling } = body;
|
||||||
const { isRecurring } = body;
|
const { isRecurring } = body;
|
||||||
|
|
||||||
@ -331,6 +331,7 @@ export async function POST(request: NextRequest) {
|
|||||||
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
|
somedaySlotIndex: somedaySlotIndex !== undefined ? parseInt(somedaySlotIndex) : null,
|
||||||
parentTaskId: parentTaskId || null,
|
parentTaskId: parentTaskId || null,
|
||||||
...(projectId !== undefined && { projectId: projectId || null }),
|
...(projectId !== undefined && { projectId: projectId || null }),
|
||||||
|
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
|
||||||
...(externalId && { externalId, externalProvider, externalListId }),
|
...(externalId && { externalId, externalProvider, externalListId }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -374,7 +375,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const { id } = body;
|
const { id } = body;
|
||||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, externalProvider } = body;
|
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, somedaySlotIndex, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, recurrenceDays, restore, parentTaskId, projectId, kanbanStage, externalProvider } = body;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -463,6 +464,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
|
...(somedaySlotIndex !== undefined && { somedaySlotIndex: somedaySlotIndex !== null ? parseInt(somedaySlotIndex) : null }),
|
||||||
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
|
...(parentTaskId !== undefined && { parentTaskId: parentTaskId || null }),
|
||||||
...(projectId !== undefined && { projectId: projectId || null }),
|
...(projectId !== undefined && { projectId: projectId || null }),
|
||||||
|
...(kanbanStage !== undefined && { kanbanStage: kanbanStage || null }),
|
||||||
...(externalProvider !== undefined && { externalProvider: externalProvider || null }),
|
...(externalProvider !== undefined && { externalProvider: externalProvider || null }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export async function GET(request: NextRequest) {
|
|||||||
customWeekdayNames: true,
|
customWeekdayNames: true,
|
||||||
quoteSourceUrls: true,
|
quoteSourceUrls: true,
|
||||||
quoteLanguages: true,
|
quoteLanguages: true,
|
||||||
|
kanbanStages: true,
|
||||||
accountNumber: true,
|
accountNumber: true,
|
||||||
createdAt: true
|
createdAt: true
|
||||||
}
|
}
|
||||||
@ -131,7 +132,8 @@ export async function PATCH(request: NextRequest) {
|
|||||||
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
cwFontFamily, cwFontSize, cwFontWeight, cwColor,
|
||||||
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
|
||||||
showTaskCheckboxes, dayHeaderGap,
|
showTaskCheckboxes, dayHeaderGap,
|
||||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages
|
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||||
|
kanbanStages
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
@ -210,6 +212,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
...(customWeekdayNames !== undefined && { customWeekdayNames }),
|
...(customWeekdayNames !== undefined && { customWeekdayNames }),
|
||||||
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
|
...(quoteSourceUrls !== undefined && { quoteSourceUrls }),
|
||||||
...(quoteLanguages !== undefined && { quoteLanguages }),
|
...(quoteLanguages !== undefined && { quoteLanguages }),
|
||||||
|
...(kanbanStages !== undefined && { kanbanStages }),
|
||||||
};
|
};
|
||||||
if (password && password.trim() !== "") {
|
if (password && password.trim() !== "") {
|
||||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||||
@ -296,6 +299,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
customWeekdayNames: true,
|
customWeekdayNames: true,
|
||||||
quoteSourceUrls: true,
|
quoteSourceUrls: true,
|
||||||
quoteLanguages: true,
|
quoteLanguages: true,
|
||||||
|
kanbanStages: true,
|
||||||
accountNumber: true,
|
accountNumber: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1655,6 +1655,150 @@ h3 {
|
|||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Kanban Board */
|
||||||
|
.kanban-board {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
min-height: 300px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 320px;
|
||||||
|
flex: 1;
|
||||||
|
background: var(--weekly-bg-alt, #f8f9fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-drag-over {
|
||||||
|
box-shadow: inset 0 0 0 2px var(--weekly-accent, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 3px solid;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--weekly-text-light, #888);
|
||||||
|
background: rgba(0,0,0,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background: var(--weekly-bg, #fff);
|
||||||
|
border: 1px solid var(--weekly-border, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: box-shadow 0.15s, transform 0.1s;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-done {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-done .kanban-card-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-checkbox {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-title {
|
||||||
|
flex: 1;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-date {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--weekly-text-light, #888);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card-project {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kanban stage color indicator on weekly tasks */
|
||||||
|
.kanban-stage-indicator {
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.kanban-board {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.preferences-overlay {
|
.preferences-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@ -88,7 +88,13 @@ function setCookie(name: string, value: string, days: number = 365) {
|
|||||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewStyle = "simple" | "calendar" | "list" | "grid";
|
export type ViewStyle = "simple" | "calendar" | "list" | "grid" | "kanban";
|
||||||
|
|
||||||
|
export interface KanbanStage {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@ -129,6 +135,7 @@ export interface Task {
|
|||||||
externalListId?: string | null;
|
externalListId?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
project?: { id: string; name: string; icon?: string | null; color?: string | null } | null;
|
||||||
|
kanbanStage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalendarEvent {
|
interface CalendarEvent {
|
||||||
@ -237,6 +244,12 @@ const translations: Record<string, any> = {
|
|||||||
simpleView: "Simple",
|
simpleView: "Simple",
|
||||||
calendarView: "Calendar",
|
calendarView: "Calendar",
|
||||||
listView: "List",
|
listView: "List",
|
||||||
|
kanbanView: "Kanban",
|
||||||
|
kanbanStages: "Kanban Stages",
|
||||||
|
kanbanStagesDesc: "Define the stages for your Kanban board. Drag tasks between columns to change their stage.",
|
||||||
|
addStage: "Add stage",
|
||||||
|
stageName: "Stage name",
|
||||||
|
noStage: "No stage",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
dateFormat: "Date Format",
|
dateFormat: "Date Format",
|
||||||
timeFormat: "Time Format",
|
timeFormat: "Time Format",
|
||||||
@ -430,6 +443,12 @@ const translations: Record<string, any> = {
|
|||||||
viewStyle: "Ansichtsstil",
|
viewStyle: "Ansichtsstil",
|
||||||
simpleView: "Einfach",
|
simpleView: "Einfach",
|
||||||
calendarView: "Kalender",
|
calendarView: "Kalender",
|
||||||
|
kanbanView: "Kanban",
|
||||||
|
kanbanStages: "Kanban-Phasen",
|
||||||
|
kanbanStagesDesc: "Definiere die Phasen für dein Kanban-Board. Ziehe Aufgaben zwischen Spalten, um ihre Phase zu ändern.",
|
||||||
|
addStage: "Phase hinzufügen",
|
||||||
|
stageName: "Phasenname",
|
||||||
|
noStage: "Keine Phase",
|
||||||
listView: "Liste",
|
listView: "Liste",
|
||||||
notes: "Notizen",
|
notes: "Notizen",
|
||||||
notesSidebar: "Notizen-Seitenleiste",
|
notesSidebar: "Notizen-Seitenleiste",
|
||||||
@ -626,6 +645,12 @@ const translations: Record<string, any> = {
|
|||||||
simpleView: "Simple",
|
simpleView: "Simple",
|
||||||
calendarView: "Calendrier",
|
calendarView: "Calendrier",
|
||||||
listView: "Liste",
|
listView: "Liste",
|
||||||
|
kanbanView: "Kanban",
|
||||||
|
kanbanStages: "Étapes Kanban",
|
||||||
|
kanbanStagesDesc: "Définissez les étapes de votre tableau Kanban. Glissez les tâches entre les colonnes pour changer leur étape.",
|
||||||
|
addStage: "Ajouter une étape",
|
||||||
|
stageName: "Nom de l'étape",
|
||||||
|
noStage: "Aucune étape",
|
||||||
language: "Langue",
|
language: "Langue",
|
||||||
dateFormat: "Format de date",
|
dateFormat: "Format de date",
|
||||||
timeFormat: "Format d'heure",
|
timeFormat: "Format d'heure",
|
||||||
@ -820,6 +845,12 @@ const translations: Record<string, any> = {
|
|||||||
simpleView: "Simple",
|
simpleView: "Simple",
|
||||||
calendarView: "Calendario",
|
calendarView: "Calendario",
|
||||||
listView: "Lista",
|
listView: "Lista",
|
||||||
|
kanbanView: "Kanban",
|
||||||
|
kanbanStages: "Etapas Kanban",
|
||||||
|
kanbanStagesDesc: "Define las etapas de tu tablero Kanban. Arrastra tareas entre columnas para cambiar su etapa.",
|
||||||
|
addStage: "Añadir etapa",
|
||||||
|
stageName: "Nombre de etapa",
|
||||||
|
noStage: "Sin etapa",
|
||||||
language: "Idioma",
|
language: "Idioma",
|
||||||
dateFormat: "Formato de fecha",
|
dateFormat: "Formato de fecha",
|
||||||
timeFormat: "Formato de hora",
|
timeFormat: "Formato de hora",
|
||||||
@ -1014,6 +1045,12 @@ const translations: Record<string, any> = {
|
|||||||
simpleView: "Semplice",
|
simpleView: "Semplice",
|
||||||
calendarView: "Calendario",
|
calendarView: "Calendario",
|
||||||
listView: "Lista",
|
listView: "Lista",
|
||||||
|
kanbanView: "Kanban",
|
||||||
|
kanbanStages: "Fasi Kanban",
|
||||||
|
kanbanStagesDesc: "Definisci le fasi della tua board Kanban. Trascina le attività tra le colonne per cambiare la loro fase.",
|
||||||
|
addStage: "Aggiungi fase",
|
||||||
|
stageName: "Nome fase",
|
||||||
|
noStage: "Nessuna fase",
|
||||||
language: "Lingua",
|
language: "Lingua",
|
||||||
dateFormat: "Formato data",
|
dateFormat: "Formato data",
|
||||||
timeFormat: "Formato ora",
|
timeFormat: "Formato ora",
|
||||||
@ -1793,6 +1830,24 @@ export default function WeeklyView() {
|
|||||||
slotIdx?: number;
|
slotIdx?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple");
|
const [viewStyle, setViewStyle] = useState<ViewStyle>("simple");
|
||||||
|
const defaultKanbanStages: KanbanStage[] = [
|
||||||
|
{ id: "backlog", name: "Backlog", color: "#94a3b8" },
|
||||||
|
{ id: "todo", name: "To Do", color: "#3b82f6" },
|
||||||
|
{ id: "in-progress", name: "In Progress", color: "#f59e0b" },
|
||||||
|
{ id: "review", name: "Review", color: "#8b5cf6" },
|
||||||
|
{ id: "done", name: "Done", color: "#22c55e" },
|
||||||
|
];
|
||||||
|
const [kanbanStages, setKanbanStages] = useState<KanbanStage[]>(defaultKanbanStages);
|
||||||
|
const saveKanbanStages = async (stages: KanbanStage[]) => {
|
||||||
|
setKanbanStages(stages);
|
||||||
|
try {
|
||||||
|
await fetch("/api/user/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ kanbanStages: JSON.stringify(stages) }),
|
||||||
|
});
|
||||||
|
} catch (e) { console.error("Failed to save kanban stages:", e); }
|
||||||
|
};
|
||||||
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
const [protectEventTimes, setProtectEventTimes] = useState(false);
|
||||||
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
const [unlockedEvents, setUnlockedEvents] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
@ -2553,6 +2608,12 @@ export default function WeeklyView() {
|
|||||||
setViewStyle(data.user.viewStyle as ViewStyle);
|
setViewStyle(data.user.viewStyle as ViewStyle);
|
||||||
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
setShowTimeGrid(data.user.showTimeGrid ?? true);
|
||||||
}
|
}
|
||||||
|
if (data.user.kanbanStages) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.user.kanbanStages);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) setKanbanStages(parsed);
|
||||||
|
} catch { /* use defaults */ }
|
||||||
|
}
|
||||||
if (data.user.viewDays !== undefined) {
|
if (data.user.viewDays !== undefined) {
|
||||||
savedViewDaysRef.current = data.user.viewDays;
|
savedViewDaysRef.current = data.user.viewDays;
|
||||||
const width = window.innerWidth;
|
const width = window.innerWidth;
|
||||||
@ -5772,8 +5833,158 @@ export default function WeeklyView() {
|
|||||||
{/* All-Day Events Section (above position) */}
|
{/* All-Day Events Section (above position) */}
|
||||||
{allDayPosition === "above" && allDaySection}
|
{allDayPosition === "above" && allDaySection}
|
||||||
|
|
||||||
|
{/* Kanban Board View */}
|
||||||
|
{viewStyle === "kanban" && (
|
||||||
|
<div className="kanban-board">
|
||||||
|
{kanbanStages.map((stage) => {
|
||||||
|
const stageTasks = tasks.filter(t => (t.kanbanStage || null) === stage.id && !t.somedayListId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className="kanban-column"
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes("text/kanban-task")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
e.currentTarget.classList.add("kanban-column-drag-over");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={async (e) => {
|
||||||
|
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||||||
|
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||||||
|
if (taskId) {
|
||||||
|
e.preventDefault();
|
||||||
|
await updateTaskFields(taskId, { kanbanStage: stage.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="kanban-column-header" style={{ borderBottomColor: stage.color }}>
|
||||||
|
<span className="kanban-column-dot" style={{ background: stage.color }} />
|
||||||
|
<span className="kanban-column-title">{stage.name}</span>
|
||||||
|
<span className="kanban-column-count">{stageTasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kanban-column-body">
|
||||||
|
{stageTasks.map(task => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`kanban-card ${task.completed ? "kanban-card-done" : ""}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData("text/kanban-task", task.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="kanban-card-header">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={task.completed}
|
||||||
|
onChange={() => toggleTask(task.id)}
|
||||||
|
className="kanban-card-checkbox"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="kanban-card-title"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onBlur={(e) => {
|
||||||
|
const text = (e.target as HTMLElement).textContent || "";
|
||||||
|
if (text !== task.title) updateTask(task.id, text);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLElement).blur(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.scheduledDate && (
|
||||||
|
<div className="kanban-card-date">
|
||||||
|
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.project && (
|
||||||
|
<div className="kanban-card-project" style={{ color: task.project.color || "#888" }}>
|
||||||
|
{task.project.icon || "📁"} {task.project.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Unassigned column */}
|
||||||
|
{(() => {
|
||||||
|
const unassigned = tasks.filter(t => !t.kanbanStage && !t.somedayListId && !t.completed);
|
||||||
|
if (unassigned.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="kanban-column kanban-column-unassigned"
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer.types.includes("text/kanban-task")) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.add("kanban-column-drag-over");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={async (e) => {
|
||||||
|
e.currentTarget.classList.remove("kanban-column-drag-over");
|
||||||
|
const taskId = e.dataTransfer.getData("text/kanban-task");
|
||||||
|
if (taskId) {
|
||||||
|
e.preventDefault();
|
||||||
|
await updateTaskFields(taskId, { kanbanStage: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="kanban-column-header" style={{ borderBottomColor: "#d1d5db" }}>
|
||||||
|
<span className="kanban-column-dot" style={{ background: "#d1d5db" }} />
|
||||||
|
<span className="kanban-column-title">{t.noStage}</span>
|
||||||
|
<span className="kanban-column-count">{unassigned.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kanban-column-body">
|
||||||
|
{unassigned.map(task => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="kanban-card"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData("text/kanban-task", task.id);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="kanban-card-header">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={task.completed}
|
||||||
|
onChange={() => toggleTask(task.id)}
|
||||||
|
className="kanban-card-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="kanban-card-title">{task.title}</span>
|
||||||
|
</div>
|
||||||
|
{task.scheduledDate && (
|
||||||
|
<div className="kanban-card-date">
|
||||||
|
{new Date(task.scheduledDate).toLocaleDateString(language, { month: "short", day: "numeric" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Grid with Time Column */}
|
{/* Main Grid with Time Column */}
|
||||||
<div className="time-grid-wrapper">
|
{viewStyle !== "kanban" && <div className="time-grid-wrapper">
|
||||||
{/* Side Navigation Arrows (hover overlays) */}
|
{/* Side Navigation Arrows (hover overlays) */}
|
||||||
<div className="side-nav side-nav-left">
|
<div className="side-nav side-nav-left">
|
||||||
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
|
<button onClick={goToPrevDay} title="Previous Day" className="side-nav-btn">
|
||||||
@ -6365,6 +6576,7 @@ export default function WeeklyView() {
|
|||||||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectAssign={assignProject}
|
onProjectAssign={assignProject}
|
||||||
|
kanbanStages={kanbanStages}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@ -6384,7 +6596,7 @@ export default function WeeklyView() {
|
|||||||
<ChevronsLeft size={16} className="rotate-180" />
|
<ChevronsLeft size={16} className="rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* All-Day Events Section (below position) */}
|
{/* All-Day Events Section (below position) */}
|
||||||
{allDayPosition === "below" && allDaySection}
|
{allDayPosition === "below" && allDaySection}
|
||||||
@ -7038,6 +7250,7 @@ export default function WeeklyView() {
|
|||||||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectAssign={assignProject}
|
onProjectAssign={assignProject}
|
||||||
|
kanbanStages={kanbanStages}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
|
activeAddSlot?.listId === list.id && activeAddSlot?.slotIdx === slot.index && (
|
||||||
@ -7108,6 +7321,7 @@ export default function WeeklyView() {
|
|||||||
showTaskCheckboxes={profile.showTaskCheckboxes}
|
showTaskCheckboxes={profile.showTaskCheckboxes}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectAssign={assignProject}
|
onProjectAssign={assignProject}
|
||||||
|
kanbanStages={kanbanStages}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -7569,6 +7783,8 @@ export default function WeeklyView() {
|
|||||||
setCurrentWeekStart={setCurrentWeekStart}
|
setCurrentWeekStart={setCurrentWeekStart}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectsChanged={fetchProjects}
|
onProjectsChanged={fetchProjects}
|
||||||
|
kanbanStages={kanbanStages}
|
||||||
|
saveKanbanStages={saveKanbanStages}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -7849,6 +8065,7 @@ interface TaskItemProps {
|
|||||||
showTaskCheckboxes?: boolean;
|
showTaskCheckboxes?: boolean;
|
||||||
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
projects?: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||||||
|
kanbanStages?: KanbanStage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskItem({
|
function TaskItem({
|
||||||
@ -7875,6 +8092,7 @@ function TaskItem({
|
|||||||
showTaskCheckboxes = false,
|
showTaskCheckboxes = false,
|
||||||
projects = [],
|
projects = [],
|
||||||
onProjectAssign,
|
onProjectAssign,
|
||||||
|
kanbanStages = [],
|
||||||
}: TaskItemProps) {
|
}: TaskItemProps) {
|
||||||
const [editValue, setEditValue] = useState(task.title);
|
const [editValue, setEditValue] = useState(task.title);
|
||||||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||||||
@ -7994,7 +8212,12 @@ function TaskItem({
|
|||||||
<li
|
<li
|
||||||
ref={taskItemRef}
|
ref={taskItemRef}
|
||||||
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
|
className={`weekly-task-item ${variant} ${task.completed ? "completed" : ""} ${task.completed && showTaskCheckboxes ? "completed-with-checkbox" : ""} ${isSomeday ? "relative mx-2 w-full" : ""} ${touchActive ? "touch-active" : ""} ${swipeX !== 0 ? "task-swipe-container" : ""}`}
|
||||||
style={task.project?.color ? { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" } : undefined}
|
style={(() => {
|
||||||
|
const stageColor = task.kanbanStage ? kanbanStages.find(s => s.id === task.kanbanStage)?.color : null;
|
||||||
|
if (stageColor) return { borderLeft: `4px solid ${stageColor}`, paddingLeft: "6px" };
|
||||||
|
if (task.project?.color) return { borderLeft: `3px solid ${task.project.color}`, paddingLeft: "6px" };
|
||||||
|
return undefined;
|
||||||
|
})()}
|
||||||
draggable={!isEditing && !isNotesOpen && swipeX === 0}
|
draggable={!isEditing && !isNotesOpen && swipeX === 0}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
// If dragging a subtask, don't drag the parent
|
// If dragging a subtask, don't drag the parent
|
||||||
@ -8862,6 +9085,8 @@ interface SettingsSidebarProps {
|
|||||||
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
|
initialTab?: "calendar" | "general" | "account" | "styling" | "motivation" | "about";
|
||||||
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
projects: { id: string; name: string; icon?: string | null; color?: string | null }[];
|
||||||
onProjectsChanged: () => void;
|
onProjectsChanged: () => void;
|
||||||
|
kanbanStages: KanbanStage[];
|
||||||
|
saveKanbanStages: (stages: KanbanStage[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
// Notes Sidebar Component
|
// Notes Sidebar Component
|
||||||
interface NotesSidebarProps {
|
interface NotesSidebarProps {
|
||||||
@ -9080,6 +9305,8 @@ function SettingsSidebar({
|
|||||||
setCurrentWeekStart,
|
setCurrentWeekStart,
|
||||||
projects,
|
projects,
|
||||||
onProjectsChanged,
|
onProjectsChanged,
|
||||||
|
kanbanStages,
|
||||||
|
saveKanbanStages,
|
||||||
}: SettingsSidebarProps) {
|
}: SettingsSidebarProps) {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
"calendar" | "general" | "account" | "styling" | "motivation" | "about" | "localisation"
|
||||||
@ -10324,9 +10551,74 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{t.listView}
|
{t.listView}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setViewStyle("kanban");
|
||||||
|
saveSetting("viewStyle", "kanban");
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 text-sm rounded transition-colors ${viewStyle === "kanban" ? "bg-white shadow-sm font-bold text-black" : "text-gray-500 hover:text-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
{t.kanbanView}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Kanban Stages Settings */}
|
||||||
|
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
||||||
|
<h4 style={{ fontSize: "0.95rem", fontWeight: 700, marginBottom: "8px", display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<LayoutGrid size={16} /> {t.kanbanStages}
|
||||||
|
</h4>
|
||||||
|
<p style={{ fontSize: "0.8rem", color: "#888", marginBottom: "12px" }}>{t.kanbanStagesDesc}</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginBottom: "12px" }}>
|
||||||
|
{kanbanStages.map((stage, idx) => (
|
||||||
|
<div key={stage.id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "4px 8px", borderRadius: "6px", background: "var(--bg-secondary, #f9fafb)" }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={stage.color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, color: e.target.value } : s);
|
||||||
|
saveKanbanStages(updated);
|
||||||
|
}}
|
||||||
|
style={{ width: "24px", height: "24px", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={stage.name}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const updated = kanbanStages.map((s, i) => i === idx ? { ...s, name: e.target.value } : s);
|
||||||
|
saveKanbanStages(updated);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||||
|
className="weekly-input"
|
||||||
|
style={{ flex: 1, padding: "4px 8px", fontSize: "0.85rem" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const updated = kanbanStages.filter((_, i) => i !== idx);
|
||||||
|
saveKanbanStages(updated);
|
||||||
|
}}
|
||||||
|
style={{ padding: "2px", opacity: 0.5, cursor: "pointer", color: "#ef4444", background: "none", border: "none" }}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const id = `stage-${Date.now()}`;
|
||||||
|
saveKanbanStages([...kanbanStages, { id, name: t.stageName, color: "#6b7280" }]);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: "4px",
|
||||||
|
background: "none", border: "1px dashed #ccc", borderRadius: "6px",
|
||||||
|
padding: "6px 12px", cursor: "pointer", color: "#888", fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {t.addStage}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Projects Section */}
|
{/* Projects Section */}
|
||||||
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
<div style={{ marginTop: "24px", borderTop: "1px solid var(--border-color, #e5e7eb)", paddingTop: "16px" }}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user