Fix test failures and workload/websocket behavior
This commit is contained in:
252
frontend/e2e/admin-flow.spec.ts
Normal file
252
frontend/e2e/admin-flow.spec.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { expect, test, Page } from 'playwright/test'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const runId = new Date().toISOString().replace(/[-:.TZ]/g, '')
|
||||
const spaceName = `E2E Space ${runId}`
|
||||
const projectName = `E2E Project ${runId}`
|
||||
const taskOneTitle = `E2E Task One ${runId}`
|
||||
const taskTwoTitle = `E2E Task Two ${runId}`
|
||||
const subtaskTitle = `E2E Subtask ${runId}`
|
||||
const commentText = `E2E Comment ${runId}`
|
||||
const customNumberField = `sp_${runId}`
|
||||
const customFormulaField = `double_sp_${runId}`
|
||||
const attachmentPath = path.join(__dirname, 'fixtures', 'attachment.txt')
|
||||
|
||||
const formatDateInput = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const setEnglishLocale = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('i18nextLng', 'en')
|
||||
})
|
||||
}
|
||||
|
||||
const openProjectFromSpaces = async (page: Page) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe.serial('admin e2e flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setEnglishLocale(page)
|
||||
})
|
||||
|
||||
test('login and dashboard', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.getByRole('heading', { name: /Welcome back/i })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: 'Quick Actions' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('create space and project', async ({ page }) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Create Space/i }).click()
|
||||
const spaceModal = page.getByRole('dialog', { name: 'Create Space' })
|
||||
await spaceModal.getByLabel('Name').fill(spaceName)
|
||||
await spaceModal.getByLabel('Description').fill('E2E space description')
|
||||
await spaceModal.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /New Project/i }).click()
|
||||
const projectModal = page.getByRole('dialog', { name: 'Create Project' })
|
||||
await projectModal.getByText('Blank Project', { exact: true }).click()
|
||||
await projectModal.locator('#project-title').fill(projectName)
|
||||
await projectModal.locator('#project-description').fill('E2E project description')
|
||||
await projectModal.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Projects: ${projectName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('project settings: members and custom fields', async ({ page }) => {
|
||||
await openProjectFromSpaces(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Settings' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Project Settings' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Members' }).click()
|
||||
await expect(page.getByText('Member Management')).toBeVisible()
|
||||
await expect(page.getByText('User')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Custom Fields' }).click()
|
||||
await expect(page.getByRole('button', { name: /Add Field/i })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Field/i }).click()
|
||||
const customModal = page.getByRole('dialog', { name: 'Create Field' })
|
||||
await customModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customNumberField)
|
||||
await customModal.getByText('Number', { exact: true }).click()
|
||||
await customModal.getByRole('button', { name: 'Create Field' }).click()
|
||||
await expect(page.getByText(customNumberField)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Field/i }).click()
|
||||
const formulaModal = page.getByRole('dialog', { name: 'Create Field' })
|
||||
await formulaModal.getByPlaceholder('e.g., Story Points, Sprint Number').fill(customFormulaField)
|
||||
await formulaModal.getByText('Formula', { exact: true }).click()
|
||||
await formulaModal.getByPlaceholder('e.g., {time_spent} / {original_estimate} * 100')
|
||||
.fill(`{${customNumberField}} * 2`)
|
||||
await formulaModal.getByRole('button', { name: 'Create Field' }).click()
|
||||
|
||||
await expect(page.getByText(customFormulaField)).toBeVisible()
|
||||
})
|
||||
|
||||
test('tasks flow: create, views, detail, attachments, comments, subtasks, dependencies', async ({ page }) => {
|
||||
await openProjectFromSpaces(page)
|
||||
|
||||
const today = new Date()
|
||||
const startDate = formatDateInput(today)
|
||||
const dueDate = formatDateInput(new Date(today.getTime() + 3 * 24 * 60 * 60 * 1000))
|
||||
|
||||
await page.getByRole('button', { name: /Create Task/i }).click()
|
||||
const createModal = page.getByRole('dialog', { name: 'Create Task' })
|
||||
await createModal.locator('#task-title').fill(taskOneTitle)
|
||||
await createModal.locator('#task-description').fill('E2E task description')
|
||||
await createModal.locator('select').first().selectOption('low')
|
||||
const dateInputs = createModal.locator('input[type="date"]')
|
||||
await dateInputs.nth(0).fill(startDate)
|
||||
await dateInputs.nth(1).fill(dueDate)
|
||||
const customFieldContainer = createModal.locator('label', { hasText: customNumberField }).locator('..')
|
||||
await customFieldContainer.locator('input[type="number"]').fill('5')
|
||||
await createModal.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText(taskOneTitle)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Create Task/i }).click()
|
||||
const createModalTwo = page.getByRole('dialog', { name: 'Create Task' })
|
||||
await createModalTwo.locator('#task-title').fill(taskTwoTitle)
|
||||
await createModalTwo.locator('#task-description').fill('E2E task description 2')
|
||||
const dateInputsTwo = createModalTwo.locator('input[type="date"]')
|
||||
await dateInputsTwo.nth(0).fill(startDate)
|
||||
await dateInputsTwo.nth(1).fill(dueDate)
|
||||
await createModalTwo.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(page.getByText(taskTwoTitle)).toBeVisible()
|
||||
await expect(page.getByText(`${customNumberField}: 5`, { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Kanban' }).click()
|
||||
await expect(page.getByText(taskOneTitle)).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Calendar' }).click()
|
||||
await expect(page.getByText(new RegExp(taskOneTitle))).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Gantt' }).click()
|
||||
await expect(page.getByText('Task Dependencies')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Manage Dependencies' }).first().click()
|
||||
const depsHeading = page.getByRole('heading', {
|
||||
name: /Manage Dependencies for/i,
|
||||
})
|
||||
await expect(depsHeading).toBeVisible()
|
||||
const depsDialog = depsHeading.locator('..')
|
||||
const dependencySelect = depsDialog.locator('select').first()
|
||||
await dependencySelect.selectOption({ index: 1 })
|
||||
await depsDialog.getByRole('button', { name: 'Add Dependency' }).click()
|
||||
await expect(page.locator('text=Depends on:').first()).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'List' }).click()
|
||||
await page.getByText(taskOneTitle).first().click()
|
||||
|
||||
const taskModal = page.getByRole('dialog', { name: taskOneTitle })
|
||||
await expect(taskModal.getByRole('heading', { name: taskOneTitle })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click()
|
||||
const descriptionField = page.locator('label', { hasText: 'Description' }).locator('..').locator('textarea')
|
||||
await descriptionField.fill('Updated task description')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(page.getByText('Updated task description')).toBeVisible()
|
||||
|
||||
await page.getByPlaceholder('Add a comment... Use @name to mention someone').fill(commentText)
|
||||
await page.getByRole('button', { name: 'Post Comment' }).click()
|
||||
await expect(page.getByText(commentText)).toBeVisible()
|
||||
|
||||
await page.locator('input[type="file"]').setInputFiles(attachmentPath)
|
||||
await expect(page.getByText('attachment.txt')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: /Add Subtask/i }).click()
|
||||
await page.locator('#new-subtask-title').fill(subtaskTitle)
|
||||
await page.getByRole('button', { name: 'Add' }).click()
|
||||
await expect(page.getByText(subtaskTitle)).toBeVisible()
|
||||
|
||||
await page.getByLabel('Close').click()
|
||||
})
|
||||
|
||||
test('notifications and my settings', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Notifications' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible()
|
||||
|
||||
await page.goto('/my-settings')
|
||||
await expect(page.getByRole('heading', { name: 'My Settings' })).toBeVisible()
|
||||
|
||||
const capacityInput = page.locator('input[type="number"]').first()
|
||||
const currentCapacity = await capacityInput.inputValue()
|
||||
const nextCapacity = String((Number(currentCapacity) || 40) + 1)
|
||||
await capacityInput.fill(nextCapacity)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await capacityInput.fill(currentCapacity || '40')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
const weeklyCard = page.getByRole('heading', { name: 'Weekly Report' }).locator('..')
|
||||
const subscriptionToggle = weeklyCard.locator('input[type="checkbox"]')
|
||||
await expect(subscriptionToggle).toBeEnabled()
|
||||
const isChecked = await subscriptionToggle.isChecked()
|
||||
await subscriptionToggle.click()
|
||||
if (isChecked) {
|
||||
await expect(subscriptionToggle).not.toBeChecked()
|
||||
} else {
|
||||
await expect(subscriptionToggle).toBeChecked()
|
||||
}
|
||||
await subscriptionToggle.click()
|
||||
})
|
||||
|
||||
test('workload, project health, audit pages', async ({ page }) => {
|
||||
await page.goto('/workload')
|
||||
await expect(page.getByRole('heading', { name: 'Workload' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Next week' }).click()
|
||||
|
||||
await page.goto('/project-health')
|
||||
await expect(page.getByRole('heading', { name: 'Project Health Dashboard' })).toBeVisible()
|
||||
await page.getByLabel('Sort by:').selectOption('name')
|
||||
|
||||
await page.goto('/audit')
|
||||
await expect(page.getByRole('heading', { name: 'Audit Log' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('cleanup: delete project and space', async ({ page }) => {
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
await page.getByRole('button', { name: `Spaces: ${spaceName}` }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible()
|
||||
|
||||
const projectCard = page.getByRole('button', { name: `Projects: ${projectName}` })
|
||||
await projectCard.getByRole('button', { name: 'Delete Project' }).click()
|
||||
const deleteProjectModal = page.getByRole('dialog', { name: 'Delete Project' })
|
||||
await deleteProjectModal.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: `Projects: ${projectName}` })).toHaveCount(0)
|
||||
|
||||
await page.goto('/spaces')
|
||||
await expect(page.getByRole('heading', { name: 'Spaces' })).toBeVisible()
|
||||
|
||||
const spaceCard = page.getByRole('button', { name: `Spaces: ${spaceName}` })
|
||||
await spaceCard.getByRole('button', { name: 'Delete Space' }).click()
|
||||
const deleteSpaceModal = page.getByRole('dialog', { name: 'Delete Space' })
|
||||
await deleteSpaceModal.getByRole('button', { name: 'Delete' }).click()
|
||||
await expect(page.getByRole('button', { name: `Spaces: ${spaceName}` })).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
1
frontend/e2e/fixtures/attachment.txt
Normal file
1
frontend/e2e/fixtures/attachment.txt
Normal file
@@ -0,0 +1 @@
|
||||
Playwright attachment fixture for Project Control e2e tests.
|
||||
43
frontend/e2e/global-setup.ts
Normal file
43
frontend/e2e/global-setup.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { chromium, FullConfig } from 'playwright/test'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const storageStatePath = path.join(__dirname, '.auth', 'admin.json')
|
||||
const baseUrl = process.env.E2E_BASE_URL || 'http://localhost:3000'
|
||||
|
||||
const requireEnv = (name: string) => {
|
||||
const value = process.env[name]
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function globalSetup(_config: FullConfig) {
|
||||
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const context = await browser.newContext()
|
||||
const page = await context.newPage()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('i18nextLng', 'en')
|
||||
})
|
||||
|
||||
const email = requireEnv('E2E_EMAIL')
|
||||
const password = requireEnv('E2E_PASSWORD')
|
||||
|
||||
await page.goto(`${baseUrl}/login`)
|
||||
await page.getByLabel('Email').fill(email)
|
||||
await page.getByLabel('Password').fill(password)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await page.getByRole('button', { name: 'Logout' }).waitFor()
|
||||
|
||||
await context.storageState({ path: storageStatePath })
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
export default globalSetup
|
||||
6
frontend/e2e/smoke.spec.ts
Normal file
6
frontend/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from 'playwright/test';
|
||||
|
||||
test('smoke: basic rendering works', async ({ page }) => {
|
||||
await page.setContent('<main><h1>Smoke</h1><p>Playwright ready</p></main>');
|
||||
await expect(page.getByRole('heading', { name: 'Smoke' })).toBeVisible();
|
||||
});
|
||||
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.16"
|
||||
@@ -2787,6 +2788,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"playwright": "^1.57.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
22
frontend/playwright.config.ts
Normal file
22
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'e2e',
|
||||
testMatch: '**/*.spec.ts',
|
||||
outputDir: 'test-results/playwright',
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000',
|
||||
storageState: 'e2e/.auth/admin.json',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,91 @@
|
||||
"capacityHelp": "Recommended: 40 hours (standard work week). Maximum: 168 hours (total hours in a week).",
|
||||
"capacitySaved": "Capacity settings saved",
|
||||
"capacityError": "Failed to save capacity settings",
|
||||
"capacityInvalid": "Please enter a valid number of hours (0-168)"
|
||||
"capacityInvalid": "Please enter a valid number of hours (0-168)",
|
||||
"weeklyReportTitle": "Weekly Report",
|
||||
"weeklyReportDescription": "Subscribe to receive a weekly in-app report for projects you own or are a member of.",
|
||||
"weeklyReportSubscribe": "Weekly report subscription",
|
||||
"weeklyReportEnabled": "Weekly report subscription enabled",
|
||||
"weeklyReportDisabled": "Weekly report subscription disabled",
|
||||
"weeklyReportError": "Failed to update weekly report subscription"
|
||||
},
|
||||
"triggers": {
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"conditions": "Conditions",
|
||||
"conditionsHint": "All conditions must match (AND).",
|
||||
"field": "Field",
|
||||
"operator": "Operator",
|
||||
"value": "Value",
|
||||
"addCondition": "Add condition",
|
||||
"and": "AND",
|
||||
"action": "Action",
|
||||
"notify": "Notify",
|
||||
"templateLabel": "Message template (optional)",
|
||||
"templateHint": "Variables: {task_title}, {old_value}, {new_value}",
|
||||
"selectCustomField": "Select custom field",
|
||||
"saving": "Saving...",
|
||||
"update": "Update",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"retry": "Retry",
|
||||
"empty": "No triggers configured for this project.",
|
||||
"when": "When",
|
||||
"then": "Then",
|
||||
"deleteTitle": "Delete Trigger",
|
||||
"deleteMessage": "Are you sure you want to delete this trigger? This action cannot be undone.",
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"assignee": "Assignee",
|
||||
"priority": "Priority",
|
||||
"startDate": "Start date",
|
||||
"dueDate": "Due date",
|
||||
"customField": "Custom field"
|
||||
},
|
||||
"operators": {
|
||||
"equals": "equals",
|
||||
"notEquals": "does not equal",
|
||||
"changedTo": "changes to",
|
||||
"changedFrom": "changes from",
|
||||
"before": "before",
|
||||
"after": "after",
|
||||
"in": "in"
|
||||
},
|
||||
"targets": {
|
||||
"assignee": "Task Assignee",
|
||||
"creator": "Task Creator",
|
||||
"projectOwner": "Project Owner",
|
||||
"projectMembers": "Project Members",
|
||||
"user": "Specific user",
|
||||
"department": "Department",
|
||||
"role": "Role"
|
||||
},
|
||||
"placeholders": {
|
||||
"value": "Enter value",
|
||||
"list": "Comma-separated values",
|
||||
"user": "User ID",
|
||||
"department": "Department ID",
|
||||
"role": "Role name"
|
||||
},
|
||||
"range": {
|
||||
"start": "Start",
|
||||
"end": "End"
|
||||
},
|
||||
"toasts": {
|
||||
"enabled": "Trigger enabled",
|
||||
"disabled": "Trigger disabled",
|
||||
"deleteSuccess": "Trigger deleted successfully"
|
||||
},
|
||||
"errors": {
|
||||
"saveFailed": "Failed to save trigger",
|
||||
"loadFailed": "Failed to load triggers",
|
||||
"updateFailed": "Failed to update trigger",
|
||||
"deleteFailed": "Failed to delete trigger",
|
||||
"missingCustomField": "Please select a custom field",
|
||||
"missingValue": "Please enter a value",
|
||||
"missingDateRange": "Please select a start and end date",
|
||||
"missingTargetValue": "Please enter a target value",
|
||||
"missingConditions": "Please add at least one condition"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
|
||||
@@ -15,7 +15,91 @@
|
||||
"capacityHelp": "建議值:40 小時(標準工時)。最大值:168 小時(一週總時數)。",
|
||||
"capacitySaved": "容量設定已儲存",
|
||||
"capacityError": "儲存容量設定失敗",
|
||||
"capacityInvalid": "請輸入有效的時數(0-168)"
|
||||
"capacityInvalid": "請輸入有效的時數(0-168)",
|
||||
"weeklyReportTitle": "每週報告",
|
||||
"weeklyReportDescription": "訂閱後將收到你所擁有或參與專案的每週站內報告。",
|
||||
"weeklyReportSubscribe": "每週報告訂閱",
|
||||
"weeklyReportEnabled": "已開啟每週報告訂閱",
|
||||
"weeklyReportDisabled": "已關閉每週報告訂閱",
|
||||
"weeklyReportError": "更新每週報告訂閱失敗"
|
||||
},
|
||||
"triggers": {
|
||||
"descriptionOptional": "描述(選填)",
|
||||
"conditions": "觸發條件",
|
||||
"conditionsHint": "所有條件需同時成立(AND)。",
|
||||
"field": "欄位",
|
||||
"operator": "運算子",
|
||||
"value": "值",
|
||||
"addCondition": "新增條件",
|
||||
"and": "且",
|
||||
"action": "動作",
|
||||
"notify": "通知",
|
||||
"templateLabel": "訊息模板(選填)",
|
||||
"templateHint": "可用變數:{task_title}, {old_value}, {new_value}",
|
||||
"selectCustomField": "選擇自訂欄位",
|
||||
"saving": "儲存中...",
|
||||
"update": "更新",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"retry": "重試",
|
||||
"empty": "此專案尚未設定觸發器。",
|
||||
"when": "當",
|
||||
"then": "則",
|
||||
"deleteTitle": "刪除觸發器",
|
||||
"deleteMessage": "確定要刪除此觸發器嗎?此操作無法復原。",
|
||||
"fields": {
|
||||
"status": "狀態",
|
||||
"assignee": "負責人",
|
||||
"priority": "優先度",
|
||||
"startDate": "開始日期",
|
||||
"dueDate": "截止日期",
|
||||
"customField": "自訂欄位"
|
||||
},
|
||||
"operators": {
|
||||
"equals": "等於",
|
||||
"notEquals": "不等於",
|
||||
"changedTo": "變更為",
|
||||
"changedFrom": "變更自",
|
||||
"before": "早於",
|
||||
"after": "晚於",
|
||||
"in": "包含於"
|
||||
},
|
||||
"targets": {
|
||||
"assignee": "任務負責人",
|
||||
"creator": "任務建立者",
|
||||
"projectOwner": "專案負責人",
|
||||
"projectMembers": "專案成員",
|
||||
"user": "指定使用者",
|
||||
"department": "部門",
|
||||
"role": "角色"
|
||||
},
|
||||
"placeholders": {
|
||||
"value": "輸入值",
|
||||
"list": "以逗號分隔",
|
||||
"user": "使用者 ID",
|
||||
"department": "部門 ID",
|
||||
"role": "角色名稱"
|
||||
},
|
||||
"range": {
|
||||
"start": "開始",
|
||||
"end": "結束"
|
||||
},
|
||||
"toasts": {
|
||||
"enabled": "觸發器已啟用",
|
||||
"disabled": "觸發器已停用",
|
||||
"deleteSuccess": "觸發器已刪除"
|
||||
},
|
||||
"errors": {
|
||||
"saveFailed": "儲存觸發器失敗",
|
||||
"loadFailed": "載入觸發器失敗",
|
||||
"updateFailed": "更新觸發器失敗",
|
||||
"deleteFailed": "刪除觸發器失敗",
|
||||
"missingCustomField": "請選擇自訂欄位",
|
||||
"missingValue": "請輸入值",
|
||||
"missingDateRange": "請選擇開始與結束日期",
|
||||
"missingTargetValue": "請輸入通知對象",
|
||||
"missingConditions": "請至少新增一個條件"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"general": "一般",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { triggersApi, Trigger, TriggerCreate, TriggerCondition, TriggerAction } from '../services/triggers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
triggersApi,
|
||||
Trigger,
|
||||
TriggerCreate,
|
||||
TriggerCondition,
|
||||
TriggerAction,
|
||||
TriggerConditionRule,
|
||||
} from '../services/triggers'
|
||||
import { customFieldsApi, CustomField } from '../services/customFields'
|
||||
|
||||
interface TriggerFormProps {
|
||||
projectId: string
|
||||
@@ -8,40 +17,289 @@ interface TriggerFormProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type RuleState = {
|
||||
id: string
|
||||
field: string
|
||||
operator: string
|
||||
value: string
|
||||
field_id?: string
|
||||
range_start?: string
|
||||
range_end?: string
|
||||
}
|
||||
|
||||
const createRuleId = () => {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
const createDefaultRule = (): RuleState => ({
|
||||
id: createRuleId(),
|
||||
field: 'status_id',
|
||||
operator: 'changed_to',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const parseRuleValue = (rule: TriggerConditionRule): Pick<RuleState, 'value' | 'range_start' | 'range_end'> => {
|
||||
if (rule.operator === 'in') {
|
||||
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
|
||||
const range = rule.value as { start?: string; end?: string }
|
||||
return {
|
||||
value: '',
|
||||
range_start: range.start ? String(range.start) : '',
|
||||
range_end: range.end ? String(range.end) : '',
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rule.value)) {
|
||||
return {
|
||||
value: rule.value.map(item => String(item)).join(', '),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: rule.value != null ? String(rule.value) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFormProps) {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [field, setField] = useState('status_id')
|
||||
const [operator, setOperator] = useState('changed_to')
|
||||
const [value, setValue] = useState('')
|
||||
const [target, setTarget] = useState('assignee')
|
||||
const [rules, setRules] = useState<RuleState[]>([createDefaultRule()])
|
||||
const [targetType, setTargetType] = useState('assignee')
|
||||
const [targetValue, setTargetValue] = useState('')
|
||||
const [template, setTemplate] = useState('')
|
||||
const [isActive, setIsActive] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger) {
|
||||
setName(trigger.name)
|
||||
setDescription(trigger.description || '')
|
||||
setField(trigger.conditions.field)
|
||||
setOperator(trigger.conditions.operator)
|
||||
setValue(trigger.conditions.value)
|
||||
if (trigger.actions.length > 0) {
|
||||
setTarget(trigger.actions[0].target)
|
||||
setTemplate(trigger.actions[0].template || '')
|
||||
const fetchCustomFields = async () => {
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(projectId)
|
||||
setCustomFields(response.fields)
|
||||
} catch {
|
||||
setCustomFields([])
|
||||
}
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
fetchCustomFields()
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger) return
|
||||
|
||||
setName(trigger.name)
|
||||
setDescription(trigger.description || '')
|
||||
setIsActive(trigger.is_active)
|
||||
|
||||
const triggerConditions = trigger.conditions
|
||||
const conditionRules: TriggerConditionRule[] = Array.isArray(triggerConditions?.rules) && triggerConditions.rules.length > 0
|
||||
? triggerConditions.rules
|
||||
: triggerConditions?.field
|
||||
? [{
|
||||
field: triggerConditions.field,
|
||||
operator: triggerConditions.operator || 'changed_to',
|
||||
value: triggerConditions.value,
|
||||
field_id: triggerConditions.field_id,
|
||||
}]
|
||||
: []
|
||||
|
||||
setRules(
|
||||
conditionRules.length > 0
|
||||
? conditionRules.map(rule => ({
|
||||
id: createRuleId(),
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
field_id: rule.field_id,
|
||||
...parseRuleValue(rule),
|
||||
}))
|
||||
: [createDefaultRule()]
|
||||
)
|
||||
|
||||
if (trigger.actions.length > 0) {
|
||||
const target = trigger.actions[0].target
|
||||
const templateValue = trigger.actions[0].template || ''
|
||||
setTemplate(templateValue)
|
||||
|
||||
if (target.startsWith('user:')) {
|
||||
setTargetType('user')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else if (target.startsWith('department:')) {
|
||||
setTargetType('department')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else if (target.startsWith('role:')) {
|
||||
setTargetType('role')
|
||||
setTargetValue(target.split(':')[1] || '')
|
||||
} else {
|
||||
setTargetType(target)
|
||||
setTargetValue('')
|
||||
}
|
||||
setIsActive(trigger.is_active)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
useEffect(() => {
|
||||
if (customFields.length === 0) return
|
||||
setRules(prev => prev.map(rule => {
|
||||
if (rule.field === 'custom_fields' && !rule.field_id) {
|
||||
return { ...rule, field_id: customFields[0]?.id }
|
||||
}
|
||||
return rule
|
||||
}))
|
||||
}, [customFields])
|
||||
|
||||
const getCustomField = (fieldId?: string) => customFields.find(field => field.id === fieldId)
|
||||
|
||||
const getFieldType = (rule: RuleState) => {
|
||||
if (rule.field === 'start_date' || rule.field === 'due_date') {
|
||||
return 'date'
|
||||
}
|
||||
if (rule.field === 'custom_fields') {
|
||||
const field = getCustomField(rule.field_id)
|
||||
if (!field) return 'text'
|
||||
if (field.field_type === 'formula') return 'number'
|
||||
return field.field_type
|
||||
}
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const getOperatorOptions = (fieldType: string) => {
|
||||
const operators = ['changed_to', 'changed_from', 'equals', 'not_equals', 'in']
|
||||
if (fieldType === 'date' || fieldType === 'number') {
|
||||
operators.push('before', 'after')
|
||||
}
|
||||
return operators
|
||||
}
|
||||
|
||||
const updateRule = (ruleId: string, updates: Partial<RuleState>) => {
|
||||
setRules(prev => prev.map(rule => rule.id === ruleId ? { ...rule, ...updates } : rule))
|
||||
}
|
||||
|
||||
const handleFieldChange = (rule: RuleState, field: string) => {
|
||||
const fieldId = field === 'custom_fields' ? (rule.field_id || customFields[0]?.id) : undefined
|
||||
const updatedRule = {
|
||||
...rule,
|
||||
field,
|
||||
field_id: fieldId,
|
||||
value: '',
|
||||
range_start: '',
|
||||
range_end: '',
|
||||
}
|
||||
const fieldType = getFieldType(updatedRule)
|
||||
const operators = getOperatorOptions(fieldType)
|
||||
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
|
||||
updateRule(rule.id, { ...updatedRule, operator })
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (rule: RuleState, fieldId: string) => {
|
||||
const updatedRule = { ...rule, field_id: fieldId }
|
||||
const fieldType = getFieldType(updatedRule)
|
||||
const operators = getOperatorOptions(fieldType)
|
||||
const operator = operators.includes(updatedRule.operator) ? updatedRule.operator : operators[0]
|
||||
updateRule(rule.id, { field_id: fieldId, operator })
|
||||
}
|
||||
|
||||
const buildRulePayload = (rule: RuleState): TriggerConditionRule | null => {
|
||||
const fieldType = getFieldType(rule)
|
||||
let value: unknown = rule.value
|
||||
|
||||
if (rule.operator === 'in') {
|
||||
if (fieldType === 'date') {
|
||||
value = {
|
||||
start: rule.range_start || '',
|
||||
end: rule.range_end || '',
|
||||
}
|
||||
} else {
|
||||
value = rule.value
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
} else if (fieldType === 'number') {
|
||||
const numericValue = Number(rule.value)
|
||||
value = rule.value !== '' && !Number.isNaN(numericValue) ? numericValue : rule.value
|
||||
}
|
||||
|
||||
return {
|
||||
field: rule.field,
|
||||
operator: rule.operator,
|
||||
value,
|
||||
field_id: rule.field === 'custom_fields' ? rule.field_id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTarget = () => {
|
||||
if (['user', 'department', 'role'].includes(targetType)) {
|
||||
const trimmed = targetValue.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
return `${targetType}:${trimmed}`
|
||||
}
|
||||
return targetType
|
||||
}
|
||||
|
||||
const validateRules = () => {
|
||||
if (rules.length === 0) {
|
||||
setError(t('settings:triggers.errors.missingConditions'))
|
||||
return false
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const fieldType = getFieldType(rule)
|
||||
if (rule.field === 'custom_fields' && !rule.field_id) {
|
||||
setError(t('settings:triggers.errors.missingCustomField'))
|
||||
return false
|
||||
}
|
||||
if (rule.operator === 'in' && fieldType === 'date') {
|
||||
if (!rule.range_start || !rule.range_end) {
|
||||
setError(t('settings:triggers.errors.missingDateRange'))
|
||||
return false
|
||||
}
|
||||
} else if (rule.operator === 'in') {
|
||||
if (!rule.value.trim()) {
|
||||
setError(t('settings:triggers.errors.missingValue'))
|
||||
return false
|
||||
}
|
||||
} else if (!rule.value.trim()) {
|
||||
setError(t('settings:triggers.errors.missingValue'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTarget = resolveTarget()
|
||||
if (!resolvedTarget) {
|
||||
setError(t('settings:triggers.errors.missingTargetValue'))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!validateRules()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const conditions: TriggerCondition = { field, operator, value }
|
||||
const actions: TriggerAction[] = [{ type: 'notify', target, template: template || undefined }]
|
||||
const rulePayloads = rules
|
||||
.map(buildRulePayload)
|
||||
.filter((rule): rule is TriggerConditionRule => rule !== null)
|
||||
|
||||
const conditions: TriggerCondition = rulePayloads.length > 1
|
||||
? { logic: 'and', rules: rulePayloads }
|
||||
: rulePayloads[0]
|
||||
|
||||
const resolvedTarget = resolveTarget() || 'assignee'
|
||||
const actions: TriggerAction[] = [{ type: 'notify', target: resolvedTarget, template: template || undefined }]
|
||||
|
||||
try {
|
||||
if (trigger) {
|
||||
@@ -65,16 +323,96 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
}
|
||||
onSave()
|
||||
} catch {
|
||||
setError('Failed to save trigger')
|
||||
setError(t('settings:triggers.errors.saveFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const operatorLabel = (operator: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
equals: 'equals',
|
||||
not_equals: 'notEquals',
|
||||
changed_to: 'changedTo',
|
||||
changed_from: 'changedFrom',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
in: 'in',
|
||||
}
|
||||
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
|
||||
}
|
||||
|
||||
const fieldLabel = (field: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
status_id: 'status',
|
||||
assignee_id: 'assignee',
|
||||
priority: 'priority',
|
||||
start_date: 'startDate',
|
||||
due_date: 'dueDate',
|
||||
custom_fields: 'customField',
|
||||
}
|
||||
return t(`settings:triggers.fields.${keyMap[field] || field}`)
|
||||
}
|
||||
|
||||
const renderValueInput = (rule: RuleState) => {
|
||||
const fieldType = getFieldType(rule)
|
||||
|
||||
if (rule.operator === 'in' && fieldType === 'date') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">{t('settings:triggers.range.start')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rule.range_start || ''}
|
||||
onChange={e => updateRule(rule.id, { range_start: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500">{t('settings:triggers.range.end')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rule.range_end || ''}
|
||||
onChange={e => updateRule(rule.id, { range_end: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fieldType === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={rule.value}
|
||||
onChange={e => updateRule(rule.id, { value: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputType = fieldType === 'number' ? 'number' : 'text'
|
||||
const placeholder = rule.operator === 'in'
|
||||
? t('settings:triggers.placeholders.list')
|
||||
: t('settings:triggers.placeholders.value')
|
||||
|
||||
return (
|
||||
<input
|
||||
type={inputType}
|
||||
value={rule.value}
|
||||
onChange={e => updateRule(rule.id, { value: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('common:labels.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -85,7 +423,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description (optional)</label>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('settings:triggers.descriptionOptional')}</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
@@ -95,70 +433,128 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
</div>
|
||||
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">Condition</legend>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Field</label>
|
||||
<select
|
||||
value={field}
|
||||
onChange={e => setField(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="status_id">Status</option>
|
||||
<option value="assignee_id">Assignee</option>
|
||||
<option value="priority">Priority</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Operator</label>
|
||||
<select
|
||||
value={operator}
|
||||
onChange={e => setOperator(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="changed_to">Changes to</option>
|
||||
<option value="changed_from">Changes from</option>
|
||||
<option value="equals">Equals</option>
|
||||
<option value="not_equals">Not equals</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="UUID or value"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.conditions')}</legend>
|
||||
<p className="text-xs text-gray-500 mb-3">{t('settings:triggers.conditionsHint')}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rules.map((rule, index) => {
|
||||
const fieldType = getFieldType(rule)
|
||||
const operatorOptions = getOperatorOptions(fieldType)
|
||||
return (
|
||||
<div key={rule.id} className="space-y-2">
|
||||
<div className="grid grid-cols-12 gap-3 items-end">
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.field')}</label>
|
||||
<select
|
||||
value={rule.field}
|
||||
onChange={e => handleFieldChange(rule, e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="status_id">{fieldLabel('status_id')}</option>
|
||||
<option value="assignee_id">{fieldLabel('assignee_id')}</option>
|
||||
<option value="priority">{fieldLabel('priority')}</option>
|
||||
<option value="start_date">{fieldLabel('start_date')}</option>
|
||||
<option value="due_date">{fieldLabel('due_date')}</option>
|
||||
<option value="custom_fields">{fieldLabel('custom_fields')}</option>
|
||||
</select>
|
||||
{rule.field === 'custom_fields' && (
|
||||
<select
|
||||
value={rule.field_id || ''}
|
||||
onChange={e => handleCustomFieldChange(rule, e.target.value)}
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">{t('settings:triggers.selectCustomField')}</option>
|
||||
{customFields.map(field => (
|
||||
<option key={field.id} value={field.id}>
|
||||
{field.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.operator')}</label>
|
||||
<select
|
||||
value={rule.operator}
|
||||
onChange={e => updateRule(rule.id, { operator: e.target.value })}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
{operatorOptions.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{operatorLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.value')}</label>
|
||||
{renderValueInput(rule)}
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRules(prev => prev.filter(item => item.id !== rule.id))}
|
||||
className="text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
{t('common:buttons.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{index < rules.length - 1 && (
|
||||
<div className="text-xs text-gray-500">{t('settings:triggers.and')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRules(prev => [...prev, createDefaultRule()])}
|
||||
className="mt-3 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{t('settings:triggers.addCondition')}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">Action</legend>
|
||||
<legend className="text-sm font-medium text-gray-700 px-1">{t('settings:triggers.action')}</legend>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Notify</label>
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.notify')}</label>
|
||||
<select
|
||||
value={target}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
value={targetType}
|
||||
onChange={e => setTargetType(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="assignee">Task Assignee</option>
|
||||
<option value="creator">Task Creator</option>
|
||||
<option value="project_owner">Project Owner</option>
|
||||
<option value="assignee">{t('settings:triggers.targets.assignee')}</option>
|
||||
<option value="creator">{t('settings:triggers.targets.creator')}</option>
|
||||
<option value="project_owner">{t('settings:triggers.targets.projectOwner')}</option>
|
||||
<option value="project_members">{t('settings:triggers.targets.projectMembers')}</option>
|
||||
<option value="user">{t('settings:triggers.targets.user')}</option>
|
||||
<option value="department">{t('settings:triggers.targets.department')}</option>
|
||||
<option value="role">{t('settings:triggers.targets.role')}</option>
|
||||
</select>
|
||||
{['user', 'department', 'role'].includes(targetType) && (
|
||||
<input
|
||||
type="text"
|
||||
value={targetValue}
|
||||
onChange={e => setTargetValue(e.target.value)}
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder={t(`settings:triggers.placeholders.${targetType}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600">Message template (optional)</label>
|
||||
<label className="block text-sm text-gray-600">{t('settings:triggers.templateLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={template}
|
||||
onChange={e => setTemplate(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Variables: {task_title}, {old_value}, {new_value}"
|
||||
placeholder={t('settings:triggers.templateHint')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +568,7 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
onChange={e => setIsActive(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">Active</label>
|
||||
<label htmlFor="isActive" className="text-sm text-gray-700">{t('common:labels.active')}</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
@@ -183,14 +579,18 @@ export function TriggerForm({ projectId, trigger, onSave, onCancel }: TriggerFor
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
{t('common:buttons.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : trigger ? 'Update' : 'Create'}
|
||||
{loading
|
||||
? t('settings:triggers.saving')
|
||||
: trigger
|
||||
? t('settings:triggers.update')
|
||||
: t('common:buttons.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { triggersApi, Trigger } from '../services/triggers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { triggersApi, Trigger, TriggerConditionRule } from '../services/triggers'
|
||||
import { customFieldsApi } from '../services/customFields'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { useToast } from '../contexts/ToastContext'
|
||||
import { SkeletonList } from './Skeleton'
|
||||
@@ -10,11 +12,13 @@ interface TriggerListProps {
|
||||
}
|
||||
|
||||
export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const { showToast } = useToast()
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
const [customFieldMap, setCustomFieldMap] = useState<Record<string, string>>({})
|
||||
|
||||
const fetchTriggers = useCallback(async () => {
|
||||
try {
|
||||
@@ -23,23 +27,42 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
setTriggers(response.triggers)
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('Failed to load triggers')
|
||||
setError(t('settings:triggers.errors.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId, t])
|
||||
|
||||
const fetchCustomFields = useCallback(async () => {
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(projectId)
|
||||
const map: Record<string, string> = {}
|
||||
response.fields.forEach(field => {
|
||||
map[field.id] = field.name
|
||||
})
|
||||
setCustomFieldMap(map)
|
||||
} catch {
|
||||
setCustomFieldMap({})
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTriggers()
|
||||
}, [fetchTriggers])
|
||||
fetchCustomFields()
|
||||
}, [fetchTriggers, fetchCustomFields])
|
||||
|
||||
const handleToggleActive = async (trigger: Trigger) => {
|
||||
try {
|
||||
await triggersApi.updateTrigger(trigger.id, { is_active: !trigger.is_active })
|
||||
fetchTriggers()
|
||||
showToast(`Trigger ${trigger.is_active ? 'disabled' : 'enabled'}`, 'success')
|
||||
showToast(
|
||||
trigger.is_active
|
||||
? t('settings:triggers.toasts.disabled')
|
||||
: t('settings:triggers.toasts.enabled'),
|
||||
'success'
|
||||
)
|
||||
} catch {
|
||||
showToast('Failed to update trigger', 'error')
|
||||
showToast(t('settings:triggers.errors.updateFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,29 +73,70 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
try {
|
||||
await triggersApi.deleteTrigger(triggerId)
|
||||
fetchTriggers()
|
||||
showToast('Trigger deleted successfully', 'success')
|
||||
showToast(t('settings:triggers.toasts.deleteSuccess'), 'success')
|
||||
} catch {
|
||||
showToast('Failed to delete trigger', 'error')
|
||||
showToast(t('settings:triggers.errors.deleteFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const getFieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case 'status_id': return 'Status'
|
||||
case 'assignee_id': return 'Assignee'
|
||||
case 'priority': return 'Priority'
|
||||
default: return field
|
||||
const getRules = (conditions: Trigger['conditions']): TriggerConditionRule[] => {
|
||||
if (Array.isArray(conditions?.rules) && conditions.rules.length > 0) {
|
||||
return conditions.rules
|
||||
}
|
||||
if (conditions?.field) {
|
||||
return [{
|
||||
field: conditions.field,
|
||||
operator: conditions.operator || 'equals',
|
||||
value: conditions.value,
|
||||
field_id: conditions.field_id,
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getFieldLabel = (field: string, fieldId?: string) => {
|
||||
if (field === 'custom_fields') {
|
||||
return customFieldMap[fieldId || '']
|
||||
? `${t('settings:triggers.fields.customField')}: ${customFieldMap[fieldId || '']}`
|
||||
: t('settings:triggers.fields.customField')
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
status_id: 'status',
|
||||
assignee_id: 'assignee',
|
||||
priority: 'priority',
|
||||
start_date: 'startDate',
|
||||
due_date: 'dueDate',
|
||||
}
|
||||
return t(`settings:triggers.fields.${keyMap[field] || field}`)
|
||||
}
|
||||
|
||||
const getOperatorLabel = (operator: string) => {
|
||||
switch (operator) {
|
||||
case 'equals': return 'equals'
|
||||
case 'not_equals': return 'does not equal'
|
||||
case 'changed_to': return 'changes to'
|
||||
case 'changed_from': return 'changes from'
|
||||
default: return operator
|
||||
const keyMap: Record<string, string> = {
|
||||
equals: 'equals',
|
||||
not_equals: 'notEquals',
|
||||
changed_to: 'changedTo',
|
||||
changed_from: 'changedFrom',
|
||||
before: 'before',
|
||||
after: 'after',
|
||||
in: 'in',
|
||||
}
|
||||
return t(`settings:triggers.operators.${keyMap[operator] || operator}`)
|
||||
}
|
||||
|
||||
const formatRuleValue = (rule: TriggerConditionRule) => {
|
||||
if (rule.operator === 'in') {
|
||||
if (rule.value && typeof rule.value === 'object' && !Array.isArray(rule.value)) {
|
||||
const range = rule.value as { start?: string; end?: string }
|
||||
const start = range.start ? String(range.start) : ''
|
||||
const end = range.end ? String(range.end) : ''
|
||||
return start && end ? `${start} ~ ${end}` : start || end
|
||||
}
|
||||
if (Array.isArray(rule.value)) {
|
||||
return rule.value.map(item => String(item)).join(', ')
|
||||
}
|
||||
}
|
||||
return rule.value != null ? String(rule.value) : ''
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -84,7 +148,7 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
<div className="p-4 text-center text-red-500">
|
||||
{error}
|
||||
<button onClick={fetchTriggers} className="ml-2 text-blue-600 hover:underline">
|
||||
Retry
|
||||
{t('settings:triggers.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -93,79 +157,89 @@ export function TriggerList({ projectId, onEdit }: TriggerListProps) {
|
||||
if (triggers.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
No triggers configured for this project.
|
||||
{t('settings:triggers.empty')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{triggers.map(trigger => (
|
||||
<div
|
||||
key={trigger.id}
|
||||
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{trigger.name}</h4>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
trigger.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{trigger.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
{trigger.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
<span className="font-medium">When: </span>
|
||||
{getFieldLabel(trigger.conditions.field)} {getOperatorLabel(trigger.conditions.operator)} {trigger.conditions.value}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">Then: </span>
|
||||
{trigger.actions.map((a, i) => (
|
||||
<span key={i}>
|
||||
{a.type === 'notify' ? `Notify ${a.target}` : a.type}
|
||||
{i < trigger.actions.length - 1 && ', '}
|
||||
{triggers.map(trigger => {
|
||||
const rules = getRules(trigger.conditions)
|
||||
return (
|
||||
<div
|
||||
key={trigger.id}
|
||||
className={`border rounded-lg p-4 ${trigger.is_active ? 'bg-white' : 'bg-gray-50'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{trigger.name}</h4>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${
|
||||
trigger.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{trigger.is_active ? t('common:labels.active') : t('common:labels.inactive')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{trigger.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{trigger.description}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
<span className="font-medium">{t('settings:triggers.when')}: </span>
|
||||
{rules.map((rule, index) => (
|
||||
<span key={`${trigger.id}-${index}`}>
|
||||
{getFieldLabel(rule.field, rule.field_id)} {getOperatorLabel(rule.operator)} {formatRuleValue(rule)}
|
||||
{index < rules.length - 1 && ` ${t('settings:triggers.and')} `}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">{t('settings:triggers.then')}: </span>
|
||||
{trigger.actions.map((action, index) => (
|
||||
<span key={index}>
|
||||
{action.type === 'notify'
|
||||
? `${t('settings:triggers.notify')} ${action.target}`
|
||||
: action.type}
|
||||
{index < trigger.actions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleActive(trigger)}
|
||||
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{trigger.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
{onEdit && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(trigger)}
|
||||
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||
onClick={() => handleToggleActive(trigger)}
|
||||
className="text-sm px-2 py-1 text-gray-600 hover:bg-gray-100 rounded"
|
||||
>
|
||||
Edit
|
||||
{trigger.is_active ? t('settings:triggers.disable') : t('settings:triggers.enable')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(trigger)}
|
||||
className="text-sm px-2 py-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
{t('common:buttons.edit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(trigger.id)}
|
||||
className="text-sm px-2 py-1 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
{t('common:buttons.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={deleteConfirm !== null}
|
||||
title="Delete Trigger"
|
||||
message="Are you sure you want to delete this trigger? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
title={t('settings:triggers.deleteTitle')}
|
||||
message={t('settings:triggers.deleteMessage')}
|
||||
confirmText={t('common:buttons.delete')}
|
||||
cancelText={t('common:buttons.cancel')}
|
||||
confirmStyle="danger"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
|
||||
@@ -23,9 +23,13 @@ export default function MySettings() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [capacity, setCapacity] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weeklySubscription, setWeeklySubscription] = useState(false)
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true)
|
||||
const [subscriptionSaving, setSubscriptionSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile()
|
||||
loadWeeklySubscription()
|
||||
}, [])
|
||||
|
||||
const loadProfile = async () => {
|
||||
@@ -50,6 +54,19 @@ export default function MySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadWeeklySubscription = async () => {
|
||||
setSubscriptionLoading(true)
|
||||
try {
|
||||
const response = await api.get('/reports/weekly/subscription')
|
||||
setWeeklySubscription(Boolean(response.data?.is_active))
|
||||
} catch (err) {
|
||||
console.error('Failed to load weekly subscription:', err)
|
||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||
} finally {
|
||||
setSubscriptionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveCapacity = async () => {
|
||||
if (!profile) return
|
||||
|
||||
@@ -75,6 +92,23 @@ export default function MySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleSubscription = async (enabled: boolean) => {
|
||||
setSubscriptionSaving(true)
|
||||
try {
|
||||
await api.put('/reports/weekly/subscription', { is_active: enabled })
|
||||
setWeeklySubscription(enabled)
|
||||
showToast(
|
||||
enabled ? t('mySettings.weeklyReportEnabled') : t('mySettings.weeklyReportDisabled'),
|
||||
'success'
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to update weekly subscription:', err)
|
||||
showToast(t('mySettings.weeklyReportError'), 'error')
|
||||
} finally {
|
||||
setSubscriptionSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
@@ -160,6 +194,21 @@ export default function MySettings() {
|
||||
{saving ? t('common:labels.loading') : t('common:buttons.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.cardTitle}>{t('mySettings.weeklyReportTitle')}</h2>
|
||||
<p style={styles.cardDescription}>{t('mySettings.weeklyReportDescription')}</p>
|
||||
<div style={styles.toggleRow}>
|
||||
<label style={styles.toggleLabel}>{t('mySettings.weeklyReportSubscribe')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={weeklySubscription}
|
||||
disabled={subscriptionLoading || subscriptionSaving}
|
||||
onChange={(e) => handleToggleSubscription(e.target.checked)}
|
||||
style={styles.toggleInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,6 +316,21 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
backgroundColor: '#ccc',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
toggleRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
},
|
||||
toggleLabel: {
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
fontWeight: 500,
|
||||
},
|
||||
toggleInput: {
|
||||
width: '40px',
|
||||
height: '20px',
|
||||
},
|
||||
error: {
|
||||
padding: '48px',
|
||||
textAlign: 'center',
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import api from './api'
|
||||
|
||||
export interface TriggerCondition {
|
||||
export interface TriggerConditionRule {
|
||||
field: string
|
||||
operator: string
|
||||
value: string
|
||||
value: unknown
|
||||
field_id?: string
|
||||
}
|
||||
|
||||
export interface TriggerCondition {
|
||||
field?: string
|
||||
operator?: string
|
||||
value?: unknown
|
||||
field_id?: string
|
||||
logic?: 'and'
|
||||
rules?: TriggerConditionRule[]
|
||||
}
|
||||
|
||||
export interface TriggerAction {
|
||||
|
||||
Reference in New Issue
Block a user